mirror of
https://github.com/marcusDenslow/terminalShop.git
synced 2026-10-25 02:52:20 +02:00
feat: add OpenTelemetry tracing
This commit is contained in:
32
00
Normal file
32
00
Normal file
@@ -0,0 +1,32 @@
|
||||
package observability
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"go.opentelemetry.io/otel"
|
||||
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
|
||||
"go.opentelemetry.io/otel/propagation"
|
||||
"go.opentelemetry.io/otel/sdk/resource"
|
||||
|
||||
semconv "go.opentelemetry.io/otel/semconv/v1.26.0"
|
||||
sdktrace "go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
|
||||
func InitTracing(ctx context.Context, serviceName, endpoint string) (func(context.Context) error, error) {
|
||||
exp, err := otlptracehttp.New(ctx, otlptracehttp.WithEndpoint(endpoint), otlptracehttp.WithInsecure())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
res, err := resource.New(ctx, resource.WithAttributes(semconv.ServiceName(serviceName)),)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tp := sdktrace.NewTraceProvider(sdktrace.WithBatcher(exp, sdktrace.WithBatchTimeout(5*time.Second)), sdktrace.WithResource(res),)
|
||||
|
||||
otel.SetTracerProvider(tp)
|
||||
otel.SetTextMapPropagator(propagation.TraceContext{})
|
||||
return tp.Shutdown, nil
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
# Reference Patterns to Adopt
|
||||
|
||||
Patterns from `terminal-shop-source` that we're copying into this project.
|
||||
|
||||
---
|
||||
|
||||
## 1. Footer: Command Array Pattern
|
||||
|
||||
### Structs (in model.go or footer.go)
|
||||
```go
|
||||
type footerState struct {
|
||||
commands []footerCommand
|
||||
}
|
||||
|
||||
type footerCommand struct {
|
||||
key string
|
||||
value string
|
||||
}
|
||||
```
|
||||
|
||||
### Setting commands (in each *Switch method)
|
||||
```go
|
||||
func (m Model) CartSwitch() (Model, tea.Cmd) {
|
||||
m = m.SwitchPage(cartPage)
|
||||
m.footer.commands = []footerCommand{
|
||||
{key: "esc", value: "back"},
|
||||
{key: "↑/↓", value: "items"},
|
||||
{key: "+/-", value: "qty"},
|
||||
{key: "c", value: "checkout"},
|
||||
}
|
||||
m = m.updateCartViewport()
|
||||
return m, nil
|
||||
}
|
||||
```
|
||||
|
||||
### Rendering (footer.go)
|
||||
```go
|
||||
// Iterate commands instead of if/else chain
|
||||
commands := []string{}
|
||||
for _, cmd := range m.footer.commands {
|
||||
commands = append(commands, bold(" "+cmd.key+" ")+base(cmd.value+" "))
|
||||
}
|
||||
|
||||
// Error display replaces default content when m.ErrorMsg != ""
|
||||
```
|
||||
|
||||
### Small screen: only show "m menu"
|
||||
```go
|
||||
if m.size == small {
|
||||
return table.Render(bold("m") + base(" menu"))
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Helper Methods
|
||||
|
||||
### Cart helpers (add to model.go or cart.go)
|
||||
```go
|
||||
func (m Model) IsCartEmpty() bool {
|
||||
return len(m.Cart) == 0
|
||||
}
|
||||
|
||||
func (m Model) CartItemCount() int {
|
||||
return len(m.Cart)
|
||||
}
|
||||
|
||||
func (m Model) CalculateSubtotal() int {
|
||||
subtotal := 0
|
||||
for _, item := range m.Cart {
|
||||
subtotal += item.Coffee.Price * item.Quantity
|
||||
}
|
||||
return subtotal
|
||||
}
|
||||
```
|
||||
|
||||
### Used in reference for: header cart badge, footer conditional commands, checkout validation, review page totals.
|
||||
|
||||
---
|
||||
|
||||
## 3. Account View Split
|
||||
|
||||
### Reference splits into separate files per sub-view:
|
||||
- `account.go` — parent: menu + delegation (~80 lines)
|
||||
- `orders.go` — OrdersView() + OrdersUpdate()
|
||||
- `subscriptions.go` — SubscriptionsView() + SubscriptionsUpdate()
|
||||
- `tokens.go` — TokensView() + TokensUpdate()
|
||||
- `apps.go` — AppsView() + AppsUpdate()
|
||||
- `faq.go` — FaqView()
|
||||
- `about.go` — AboutView() (in account.go, inline)
|
||||
|
||||
### Parent dispatches via:
|
||||
```go
|
||||
func (m model) GetAccountPageContent(accountPage page, width int) string {
|
||||
switch accountPage {
|
||||
case ordersPage:
|
||||
return m.OrdersView(width, m.state.account.focused)
|
||||
case addressesPage:
|
||||
return m.AddressesView(width, m.state.account.focused)
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Each sub-view owns its own state, cursor, and deletion logic.
|
||||
276
ROADMAP.md
Normal file
276
ROADMAP.md
Normal file
@@ -0,0 +1,276 @@
|
||||
# Roadmap
|
||||
|
||||
Synthesised from a four-pronged audit (TUI, checkout, API, code quality) comparing this project against `terminal-shop-source/`. Items are grouped by impact, not by sequence — but the **Phase** ordering reflects what unblocks what. Every item links to the file path (and line where useful) so future sessions can pick up cold.
|
||||
|
||||
The legend across every entry:
|
||||
- **MATCHES** — parity with reference
|
||||
- **LACKS** — reference has it, we don't
|
||||
- **EXTRA** — we have it, reference doesn't (kept on purpose unless flagged)
|
||||
- **WORSE / BETTER** — both sides have it; relative quality
|
||||
|
||||
---
|
||||
|
||||
## Phase 0 — Hygiene (do first; small, prevents footguns)
|
||||
|
||||
These are quick wins that are blocking nothing but should be done before opening larger refactors.
|
||||
|
||||
### 0.1 Remove tracked artefacts
|
||||
- `terminalshop.db` (root) and `api/terminalshop.db` are committed SQLite DBs. May contain test users / Stripe IDs.
|
||||
- `git rm --cached terminalshop.db api/terminalshop.db`
|
||||
- Compiled binaries `main` (~20MB) and `terminalShop` (~22MB) are tracked.
|
||||
- `git rm --cached main terminalShop`
|
||||
- `server.go.old` is a stale duplicate of `main.go`. Delete.
|
||||
- `test_stripe.go` at root is dead manual test code under `//go:build ignore`. Move to `cmd/striptest/` or delete.
|
||||
- `pkg/tui/qrfefe/` — likely a typo for `qrcode`; rename and fix the unused `size` parameter in `Generate`.
|
||||
- Verify `.env` isn't tracked: `git ls-files | grep .env`. If it is, `git rm --cached .env` and **rotate any Stripe keys**.
|
||||
|
||||
### 0.2 Resolve port mismatch
|
||||
`main.go:26` hardcodes port `23456`; `pkg/config` defaults `SSH_PORT` to `23457`; `CLAUDE.md` and `README` claim `23457`. Pick one (use `cfg.SSHPort`) and update docs in `main.go` + `CLAUDE.md`.
|
||||
|
||||
### 0.3 Delete dead checkout code
|
||||
`pkg/tui/confirmation.go:14-125` defines `RenderConfirmation`, `ConfirmView`, `ConfirmUpdate` — superseded by `ReviewSwitch`/`generateReviewContent` and never called. Remove.
|
||||
|
||||
### 0.4 Wire or remove `ssh_keys.go`
|
||||
`api/handlers/ssh_keys.go` defines `GetSSHKeys`/`DeleteSSHKeys` but `api/routes/routes.go` never registers them. Wire or delete.
|
||||
|
||||
### 0.5 Lowercase error message
|
||||
`api/handlers/cards.go:348` returns `fmt.Errorf("Failed to save...")` capitalized — violates code style.
|
||||
|
||||
### 0.6 Fix red "press enter to confirm"
|
||||
`pkg/tui/confirmation.go:158` uses `m.theme.TextError().Render(...)` for an action prompt — misleading. Reference uses `TextBrand()`.
|
||||
|
||||
### 0.7 Breadcrumb medium-size threshold
|
||||
`pkg/tui/breadcrumbs.go:21-25` only abbreviates on `< large`; reference abbreviates on `small||medium`. May overflow on medium terminals.
|
||||
|
||||
---
|
||||
|
||||
## Phase 1 — Foundational systems (these compound; fix first)
|
||||
|
||||
The audits identified three foundations where every new feature lands awkwardly until addressed: theme parameterization, per-page state encapsulation, and API envelope shape.
|
||||
|
||||
### 1.1 Parameterized theme + tokens
|
||||
**Why this first:** Reference `BasicTheme(renderer, highlight *string)` swaps the highlight per selected product, giving the shop view its colour-shift effect. We hardcode highlight to `#4682B4` in `pkg/tui/theme/theme.go:36`, so the per-product theme pattern is impossible.
|
||||
|
||||
- Add `pkg/tui/theme/tokens.go` with named palette constants — every `lipgloss.Color("#XXXXXX")` and raw ANSI literal (`"196"`, `"205"`, `"52"`) currently embedded in `pkg/tui/theme/theme.go` and `pkg/tui/shop.go` (line 72,124), `pkg/tui/cart.go:54`.
|
||||
- Change `BasicTheme(renderer, highlight *string)` to accept an optional highlight; default to brand if nil.
|
||||
- Add a `Theme.ProductColor(hex string) lipgloss.Style` accessor for dynamic product colours; remove direct `lipgloss.Color(coffee.Color)` usages.
|
||||
- Make `Theme.Base()` return `t.base.Copy()` (`pkg/tui/theme/theme.go:82`) — chained mutations on the cached style currently leak across renders.
|
||||
- Add an `UpdateSelectedTheme(coffee)` call in shop nav (mirrors reference `shop.go:523-542`).
|
||||
|
||||
### 1.2 Per-page state structs (refactor `model.go`)
|
||||
**Why:** `pkg/tui/model.go` is 964 lines with ~40 boolean flags. `account.go` (428) similarly farms booleans — `addressListFocused`, `cardListFocused`, `faqFocused`, etc. Reference uses one `state` struct with per-page substructs (`root.go:89-107`) and a `[]page` array for account sub-pages (`account.go:11-17`).
|
||||
|
||||
Files to introduce inside `model.go` (or a new `model_state.go`):
|
||||
- `splashState`, `cartState`, `shippingState`, `paymentState`, `accountState`, `reviewState`, `confirmState`.
|
||||
- Replace the boolean farm in `account.go` with `selected int`, `focused bool`, and an explicit `[]page` for account sub-pages.
|
||||
|
||||
Then split `model.go` into:
|
||||
- `model.go` (~150 lines: struct + constructor)
|
||||
- `model_state.go` (per-page state structs)
|
||||
- `model_switch.go` (`*Switch` helpers + footer config)
|
||||
- `commands.go` (every `tea.Cmd` builder: `fetchCart`, `fetchOrders`, `fetchAddresses`, `saveAddress`, `fetchCards`, `deleteCard`, `checkoutWithSavedCard`, `saveCardAndConvert`, `saveCardOnlyCmd`, `collectCardCmd`, `pollCardsCmd`).
|
||||
|
||||
While here, extract `resolveCartAddressCmd(m Model) error`: `checkoutWithSavedCard` and `saveCardAndConvert` (`model.go:592-726`) duplicate ~30 lines of address-resolution logic.
|
||||
|
||||
### 1.3 Structured validation errors
|
||||
**Why:** Field-level validation is currently ad-hoc string checks (`addresses.go:69`, `cart.go:102`, `auth.go:121-127`) returning a single message. The TUI shows one error at a time even when a form has multiple bad fields. Matching the reference's exact envelope shape isn't necessary (no external client consumes it), but you do want structured per-field issues so the TUI can highlight the right input.
|
||||
|
||||
- Add a central validator middleware (or helper) that returns `{issues: [{path, message}]}` per failing field. Reference does this via Zod (`common.ts:33-117`); a minimal Go equivalent is ~50 lines.
|
||||
- Add a `pkg/utils/codes.go` with exported error-code constants — current strings (`CART_ERROR`, `INVALID_ID`, etc.) are scattered. One file makes them greppable and prevents typos like `"ADDRESS NOT FOUND"` (the bug in old `NEXT.md`).
|
||||
- Keep the `{success, data|error}` envelope as-is unless an external client ever shows up.
|
||||
|
||||
---
|
||||
|
||||
## Phase 2 — Checkout flow correctness (impact on real customers)
|
||||
|
||||
### 2.1 Shipping cost calculation + display
|
||||
**Critical gap.** `pkg/tui/confirmation.go:46-48,154-156` always renders `$0.00`. No shipping rate calculation exists anywhere. Reference pulls `m.cart.Amount.Shipping` from the API.
|
||||
|
||||
- Add a `pkg/shipping` rate calculation (Shippo client already exists for address validation — extend it).
|
||||
- Backend: have `/cart` and `/cart/convert` populate `cart.Amount.Shipping`.
|
||||
- TUI: add `paymentCostsView()` (mirrors reference `payment.go:714,750-771`) shown above the card list AND inside the form.
|
||||
- TUI: render shipping line in `confirmation.go` and `confirm.go` review.
|
||||
|
||||
### 2.2 Confirm-delete pattern for addresses + cards
|
||||
Both pages currently delete immediately:
|
||||
- `pkg/tui/shipping_form.go:414-432` — destructive, no undo.
|
||||
- `pkg/tui/payment_form.go:503-521` — same.
|
||||
|
||||
Reference uses inline `y/n` confirmation tied to `state.deleting *int` (`shipping.go:251-277`, `payment.go:376-402`). Adopt the same pattern.
|
||||
|
||||
### 2.3 Auto-scroll focused form field into view
|
||||
`pkg/tui/shipping_form.go` and `pkg/tui/payment_form.go` set viewport content but never scroll to the focused input. On small terminals a focused field can be off-screen. Reference: `ensureShippingFocusedInputIsVisible` (`shipping.go:73-132`) and `ensurePaymentFocusedInputIsVisible` (`payment.go:104-165`).
|
||||
|
||||
### 2.4 Restore field-level validators
|
||||
`pkg/tui/shipping_form.go:73` removes per-field validators "so Tab/Enter move freely"; same for `payment_form.go`. Reference keeps `validate.NotEmpty` per field (`shipping.go:535-569`) for in-place feedback. Currently we rebuild the whole form on submit-error (`shipping_form.go:216,367`), losing focus.
|
||||
|
||||
### 2.5 Country list beyond {NO, US}
|
||||
`pkg/tui/shipping_form.go:24-27` hardcodes the country list. Reference accepts free-text country (`shipping.go:556-560`). Add ISO-2 validation in the API and a wider list (or free-text) in the form.
|
||||
|
||||
### 2.6 Centralize footer commands in `*Switch()` methods
|
||||
Footers are set ad-hoc inside `Update` branches (`shipping_form.go:350-354`, `payment_form.go:421-425, 487-491, 498-500, 531-536, 550-555`). Easy to drift. Reference sets them once on entry. After Phase 1.2 this becomes a one-liner per `*Switch`.
|
||||
|
||||
### 2.7 Robust `error` handling in checkout
|
||||
`pkg/tui/confirmation.go:185-231` (`ReviewUpdate`) doesn't handle a generic `error` `tea.Msg`. If the checkout cmd returns a non-`CheckoutResultMsg` error, `CheckingOut` stays `true` forever. Mirror reference's `confirm.go:101-103` and `payment.go:584-592` — clear `submitting` on any `error`.
|
||||
|
||||
### 2.8 Profile update on payment submit
|
||||
Reference `payment.go:504-514` calls `Profile.Update` to persist name/email entered during checkout. We don't (we have no profile endpoint anyway — see Phase 4.1).
|
||||
|
||||
### 2.9 Distinct submitting states / spinner messages
|
||||
Reference distinguishes "verifying payment details…" vs "generating payment link…" (`payment.go:653-659`). We show generic strings in `payment_form.go:247,370`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 3 — TUI feature parity (visible polish)
|
||||
|
||||
### 3.1 Reusable cursor + logo
|
||||
- Add `pkg/tui/cursor.go` (700ms tick, `state.cursor.visible`) — reference has one shared blink, we only blink on splash via `splashCursorTickMsg` (`model.go:151,422`).
|
||||
- Add `pkg/tui/logo.go` with `LogoView()`. Currently re-implemented inline in `splash.go:45`, `menu.go:29`, `account.go:186-210`.
|
||||
|
||||
### 3.2 Region toggle on shop view
|
||||
`r` key cycles NA / EU / Global with re-fetch (reference `shop.go:155`, `footer.go:49-82`). Touches: `pkg/tui/shop.go`, `pkg/tui/footer.go`, API products endpoint (Phase 4.4 — variants by region).
|
||||
|
||||
### 3.3 Featured / originals product sections
|
||||
Render `~ featured ~` / `~ originals ~` headers from `Tags.Featured` (reference `shop.go:348-389`). We flat-list everything (`shop.go:92-99`).
|
||||
|
||||
### 3.4 Splash error path + auth failure surface
|
||||
`pkg/tui/splash.go:35-59` has no error rendering — auth failure falls through silently (`update.go:74`). Reference handles `error != nil` with an `esc quit` hint (`splash.go:115-165`).
|
||||
|
||||
### 3.5 Footer parity
|
||||
- Top border (`footer.go:91-93` reference) — we just centre text.
|
||||
- Error banner with esc-hint and word-wrap (reference `footer.go:128-156`); ours sits above content (`views.go:76-79`) and has no esc affordance.
|
||||
- "Free shipping" hint line + region indicator (reference `footer.go:122-156`).
|
||||
|
||||
### 3.6 Account view dual viewports + scroll-pos restore
|
||||
- Reference has `menuViewport` + `detailViewport` (`account.go:14-16`); we only have `detailViewport` (`model.go:188`). Left menu doesn't scroll for users with many sub-pages.
|
||||
- Subscription scroll-pos restore — currently only orders saves `yOffset` (`account.go:337,411-414`). Add the equivalent for subscriptions when those land (Phase 4.2).
|
||||
|
||||
### 3.7 Promote about + FAQ to dedicated pages
|
||||
We render both as account sub-panels. Reference has `AboutSwitch`/`FaqSwitch` as page-level. Lower-priority polish but unblocks `aboutPage`/`faqPage` in the page enum (Phase 1.2).
|
||||
|
||||
### 3.8 Final / final-sub post-purchase pages
|
||||
Reference has `final.go` / `final-sub.go` as a separate terminal "thank-you" state with its own viewport. Our flow ends inside `confirmation.go`. Add a `finalPage` after `ReviewSubmit` succeeds.
|
||||
|
||||
### 3.9 Unified error-to-Visible-error pipeline
|
||||
`update.go:55-60` catches raw `err.Error()` and only re-fetches cart. Reference `root.go:266-278` runs `api.GetErrorMessage` to normalise messages and re-fetches the right resource per page.
|
||||
|
||||
### 3.10 Command-line entry routing
|
||||
Reference parses `command []string` from the SSH session — `ssh terminal.shop espresso` lands directly on the espresso product (`root.go:195-258`). We always splash → shop. Easy to add to `main.go` + `model.NewModelWithAuth`.
|
||||
|
||||
### 3.11 Resize debounce — keep
|
||||
`pkg/tui/update.go:14-41` debounces resize via `resizeTickMsg`. Reference resizes synchronously (`root.go:279-303`). Ours is **BETTER**; document the rationale in a comment so it isn't accidentally removed during the Phase 1.2 refactor.
|
||||
|
||||
---
|
||||
|
||||
## Phase 4 — API completeness (closes the largest functional gap)
|
||||
|
||||
Reference TS API at `terminal-shop-source/packages/functions/src/api/`. Our Go API at `api/handlers/`.
|
||||
|
||||
### 4.1 Profile resource — **missing entirely**
|
||||
- `pkg/models/profile.go` (or extend user model)
|
||||
- `api/handlers/profile.go`: `GET /profile`, `PUT /profile {name, email}`
|
||||
- Wire group in `api/routes/routes.go`
|
||||
|
||||
### 4.2 Subscriptions — **missing entirely**
|
||||
Major gap: products carry a `subscription` flag but we have no model, table, routes, or TUI flow.
|
||||
|
||||
- `pkg/models/subscription.go` + `pkg/models/subscription_schedule.go` (matches `core/src/subscription/index.ts`)
|
||||
- `api/handlers/subscriptions.go`: `GET /subscription`, `GET /:id`, `POST /`, `DELETE /:id`, `PUT /:id`
|
||||
- Reference TUI counterparts to port: `subscribe.go` (variant picker), `subscriptions.go` (list/detail/cancel)
|
||||
|
||||
### 4.3 Product variants + region filtering
|
||||
**Largest single product gap.** `pkg/models/coffee.go` has flat `price`; reference has `variants[]` with prices + inventory + `subscription` setting + `tags` + `order` (`core/src/product/index.ts:19-86`).
|
||||
|
||||
- Migrate to string IDs (`prd_*`) and add `variants` table.
|
||||
- Cart `SetItemRequest` body becomes `{productVariantID, quantity}` instead of `{coffee_id, quantity}` (`api/handlers/cart.go:87-90`).
|
||||
- Add IP geolocation (reference `ipinfo.ts`) and filter variants by `Region`.
|
||||
|
||||
### 4.4 Address / card / cart / order schema cleanup
|
||||
Reference field shapes are listed for cross-reference, but since this API only ever serves the SSH TUI client, **field renames are optional cosmetic churn** — only make changes that improve correctness or unblock other work.
|
||||
|
||||
- **Keep** (correctness / unblocks Phase 2.1):
|
||||
- Cart (`api/handlers/cart.go:43-71`): nest `amount: {subtotal, shipping, total}` and `shipping: {service, timeframe}` so the TUI can render shipping cost.
|
||||
- Addresses (`api/handlers/addresses.go:54-72`): ISO-2 country validation.
|
||||
- Orders: add `GET /orders/{id}` (Phase 3.6 order detail benefits); expose `tracking` once Phase 4.5 lands.
|
||||
- **Optional / skip unless you feel like it**: rename `street_2` → `street2`, `state` → `province`, switch SCREAMING_SNAKE error codes to lowercase, drop `is_default` on cards. None of this matters with a single in-tree client.
|
||||
- **Audit, don't necessarily remove**: `POST /orders/{id}/refund` (`orders.go:40-95`) — reference handles refunds via Stripe dashboard. If you want admin self-serve, keep it; otherwise gate behind admin auth or drop.
|
||||
|
||||
### 4.5 Shippo tracking webhook
|
||||
`api/handlers/webhook.go` doesn't handle `/webhooks/shippo`. Without this, orders never advance past `paid` to `shipped`/`delivered`. Spec: reference `hook.ts:32-82` → `Order.UpdateTrackingStatus`.
|
||||
|
||||
### 4.6 Inventory tracking on order create
|
||||
`api/handlers/cart.go:324-335` (`ConvertCart`) doesn't decrement inventory or reject out-of-stock. Reference writes `inventoryRecordTable` on every order.
|
||||
|
||||
### 4.7 Email
|
||||
- Order receipt + shipped notification. Choose a transactional provider; wire on order status transitions.
|
||||
- Newsletter signup (`POST /email`) — only if you actually plan to send a newsletter; otherwise skip.
|
||||
|
||||
### 4.8 `/view/init` parity
|
||||
`api/handlers/view.go:47-54` returns `{user, products, cart-as-items, addresses, cards, orders}`. After Phase 4.1–4.2 land, expand to include `profile`, full `Cart.Info` (with `amount`/`shipping`), `subscriptions`, and `region`. Drop `tokens` / `apps` — see out-of-scope note below.
|
||||
|
||||
---
|
||||
|
||||
## Phase 5 — Tests (lift coverage to match reference)
|
||||
|
||||
### 5.1 API handler tests
|
||||
Currently no tests for: `cards.go` (351 lines, Stripe-critical), `orders.go`, `webhook.go`, `reconcile.go`, `view.go`. Reference has matching `card.test.ts`, `order.test.ts`, `view.test.ts`.
|
||||
|
||||
Adopt the existing pattern (table-driven + `httptest`). Files to add: `api/handlers/cards_test.go`, `webhook_test.go`, `orders_test.go`, `reconcile_test.go`, `view_test.go`.
|
||||
|
||||
### 5.2 TUI Update tests
|
||||
`pkg/tui/menu_test.go` is an empty table; `pkg/tui/model_test.go` tests only `updateLayout`. No tests cover `Update`, page switches, cart math, or scroll. Add `pkg/tui/update_test.go` covering `tea.KeyMsg` for nav keys + `CartSyncedMsg` + `CheckoutResultMsg`.
|
||||
|
||||
### 5.3 Cart math + form validation
|
||||
`validatePaymentForm` is pure and easily unit-tested. `CalculateSubtotal` and cart sort order in `GetCartItemsSlice` likewise.
|
||||
|
||||
### 5.4 Token refresh test
|
||||
The JWT rotation logic at `pkg/tui/model.go:468` has no test. Add one.
|
||||
|
||||
### 5.5 Subscription / email tests
|
||||
After Phase 4.2 / 4.7 land, port reference `subscription.test.ts` and `email.test.ts`.
|
||||
|
||||
---
|
||||
|
||||
## Phase 6 — Things we do better (preserve and document)
|
||||
|
||||
These are intentional design wins worth keeping. Document them with comments at the call sites so they aren't reverted by accident.
|
||||
|
||||
- **Server-side authoritative cart** — DB-backed, can't desync between sessions. Reference is client-side optimistic.
|
||||
- **Idempotent payments** — order written before Stripe charge, with `[CRITICAL]` log on partial failure (`docs/checkout.md`).
|
||||
- **Atomic checkout transaction** — order create + cart clear + address/card sync in one DB tx.
|
||||
- **Multi-key SSH support** — reference only trusts the SSH transport key.
|
||||
- **Rate limiting** — auth (20/min), card creation (15/min), checkout (10/min). Reference has none visible.
|
||||
- **Reconciliation job** (`api/handlers/reconcile.go`) — needed because of our eager-confirm flow; reference's webhook-driven flow doesn't need one.
|
||||
- **Resize debounce** (`pkg/tui/update.go:14-41`).
|
||||
- **Custom error envelope hygiene** — handlers consistently use `utils.RespondSuccess`/`RespondError`; no `http.Error` leaks (audited, 0 hits). Keep this discipline as Phase 1.3 reshapes the envelope.
|
||||
- **Extra theme palette colours** (`success`, `loading`, `label`, `dim`) beyond reference's minimal set — flexibility for spinner / labels.
|
||||
|
||||
---
|
||||
|
||||
## Reference paths cheat-sheet
|
||||
|
||||
User project:
|
||||
- TUI: `pkg/tui/` (22 files, 4,223 lines)
|
||||
- API: `api/handlers/`, `api/middleware/`, `api/routes/`
|
||||
- Models: `pkg/models/`
|
||||
- Theme: `pkg/tui/theme/`
|
||||
- Utils: `pkg/utils/response.go`
|
||||
|
||||
Reference (`terminal-shop-source/`):
|
||||
- Go TUI: `packages/go/pkg/tui/` (27 files, 5,854 lines)
|
||||
- TS API: `packages/functions/src/api/`
|
||||
- TS domain models: `packages/core/src/{product,cart,address,card,order,user,subscription}/`
|
||||
- Errors: `packages/core/src/error.ts`
|
||||
- Tests: `packages/functions/test/api/*.test.ts`
|
||||
|
||||
---
|
||||
|
||||
## Out of scope / explicitly skipped
|
||||
|
||||
- **Personal access tokens (`trm_*`)** — reference has them so third parties can call the API from `curl` / SDKs without an SSH session. We only have one client (the SSH TUI), and the JWT it gets at splash is scoped to that session. Adding PATs would mean a new model, new middleware path, new TUI reveal-once UX, and a longer-lived secret to leak. Not worth it unless someone asks for programmatic access.
|
||||
- **OAuth apps** — reference has them so third-party websites can act on a user's behalf without seeing the user's credentials. We have no third parties. Same reasoning as above, plus a redirect flow on top.
|
||||
- **Stainless-generated SDK** — reference ships `stainless.yml` to auto-generate client libraries (JS/Python/Go). Only useful if you have programmatic API consumers, which means PATs or OAuth, which we just skipped.
|
||||
- **Reference-exact API field naming** — `street1` vs `street_2`, lowercase-snake error codes, bare `{data}` envelope. With one in-tree client these are cosmetic.
|
||||
- **Stripe Hosted Checkout → Stripe.js custom card form** — described in `docs/stripe-js-card-form.md`. Still a valid future option; not on the critical path. Revisit if branding/UX justifies it.
|
||||
- **PostgreSQL migration** — currently SQLite via SQLite driver in `pkg/database/db.go`. Move when concurrent-write contention shows up; not before.
|
||||
- **Catppuccin theme** — present in `pkg/tui/theme/theme.go:49-66` but unused. Harmless; keep or remove with no feature impact.
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
"github.com/stripe/stripe-go/v78/paymentmethod"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"go.opentelemetry.io/otel"
|
||||
|
||||
"terminalShop/api/middleware"
|
||||
"terminalShop/pkg/audit"
|
||||
"terminalShop/pkg/database"
|
||||
@@ -243,6 +245,10 @@ func (h *CartHandler) ConvertCart(w http.ResponseWriter, r *http.Request) {
|
||||
db := database.GetDB()
|
||||
userID := middleware.UserIDFromContext(r.Context())
|
||||
|
||||
ctx, span := otel.Tracer("api").Start(r.Context(), "cart.convert")
|
||||
defer span.End()
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
cart, err := getOrCreateCart(userID)
|
||||
if err != nil {
|
||||
utils.RespondError(w, http.StatusInternalServerError, "CART_ERROR", "failed to get cart", nil)
|
||||
|
||||
12
api/main.go
12
api/main.go
@@ -15,6 +15,7 @@ import (
|
||||
"terminalShop/pkg/auth"
|
||||
"terminalShop/pkg/config"
|
||||
"terminalShop/pkg/database"
|
||||
"terminalShop/pkg/observability"
|
||||
)
|
||||
|
||||
const version = "v0.1.0"
|
||||
@@ -45,6 +46,17 @@ func main() {
|
||||
log.Fatalf("Failed to seed database: %v", err)
|
||||
}
|
||||
|
||||
otlpEndpoint := os.Getenv("OTLP_ENDPOINT")
|
||||
if otlpEndpoint == "" {
|
||||
otlpEndpoint = "tempo:4318"
|
||||
}
|
||||
shutdown, err := observability.InitTracing(context.Background(), "terminalshop-api", otlpEndpoint)
|
||||
if err != nil {
|
||||
log.Printf("tracing init failed (non-fatal): %v", err)
|
||||
} else {
|
||||
defer shutdown(context.Background())
|
||||
}
|
||||
|
||||
// Init JWT manager (same secret + duration as SSH server)
|
||||
jwtManager := auth.NewJWTManager(cfg.JWTSecret, 30*time.Minute)
|
||||
|
||||
|
||||
@@ -9,6 +9,8 @@ import (
|
||||
"terminalShop/api/handlers"
|
||||
"terminalShop/api/middleware"
|
||||
"terminalShop/pkg/auth"
|
||||
|
||||
"github.com/riandyrn/otelchi"
|
||||
)
|
||||
|
||||
// SetupRoutes configures all API routes
|
||||
@@ -25,7 +27,9 @@ func SetupRoutes(
|
||||
) *chi.Mux {
|
||||
r := chi.NewRouter()
|
||||
|
||||
|
||||
// Global middleware
|
||||
r.Use(otelchi.Middleware("terminalshop-api", otelchi.WithChiRoutes(r)))
|
||||
r.Use(middleware.Logger)
|
||||
r.Use(middleware.CORS())
|
||||
r.Use(middleware.Recovery)
|
||||
|
||||
30
go.mod
30
go.mod
@@ -16,8 +16,13 @@ require (
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/prometheus/client_golang v1.23.2
|
||||
github.com/riandyrn/otelchi v0.12.2
|
||||
github.com/stripe/stripe-go/v78 v78.12.0
|
||||
golang.org/x/crypto v0.48.0
|
||||
go.opentelemetry.io/otel v1.43.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0
|
||||
go.opentelemetry.io/otel/sdk v1.43.0
|
||||
go.opentelemetry.io/otel/trace v1.43.0
|
||||
golang.org/x/crypto v0.49.0
|
||||
gorm.io/driver/postgres v1.6.0
|
||||
gorm.io/driver/sqlite v1.6.0
|
||||
gorm.io/gorm v1.31.1
|
||||
@@ -30,6 +35,7 @@ require (
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/beorn7/perks v1.0.1 // indirect
|
||||
github.com/catppuccin/go v0.3.0 // indirect
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.4.1 // indirect
|
||||
github.com/charmbracelet/keygen v0.5.3 // indirect
|
||||
@@ -49,7 +55,12 @@ require (
|
||||
github.com/creack/pty v1.1.24 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/felixge/httpsnoop v1.0.4 // indirect
|
||||
github.com/go-logfmt/logfmt v0.6.0 // indirect
|
||||
github.com/go-logr/logr v1.4.3 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
github.com/jackc/pgx/v5 v5.6.0 // indirect
|
||||
@@ -57,7 +68,6 @@ require (
|
||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||
github.com/jinzhu/now v1.1.5 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
||||
github.com/kr/text v0.2.0 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
@@ -74,10 +84,18 @@ require (
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
github.com/zeebo/xxh3 v1.0.2 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.43.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.2 // indirect
|
||||
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.41.0 // indirect
|
||||
golang.org/x/text v0.34.0 // indirect
|
||||
google.golang.org/protobuf v1.36.8 // indirect
|
||||
golang.org/x/net v0.52.0 // indirect
|
||||
golang.org/x/sync v0.20.0 // indirect
|
||||
golang.org/x/sys v0.42.0 // indirect
|
||||
golang.org/x/text v0.35.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 // indirect
|
||||
google.golang.org/grpc v1.80.0 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
)
|
||||
|
||||
76
go.sum
76
go.sum
@@ -12,6 +12,8 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
|
||||
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
|
||||
github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
|
||||
github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws=
|
||||
@@ -60,7 +62,6 @@ github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfa
|
||||
github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
|
||||
github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
|
||||
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
|
||||
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
@@ -70,6 +71,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
|
||||
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
|
||||
github.com/foize/go.sgr v0.0.0-20140220094842-40bdfc98040c h1:NhHTf8Ewd3NS46DylZB75Zi0UeATvDbDwFEmsO5ZVpQ=
|
||||
github.com/foize/go.sgr v0.0.0-20140220094842-40bdfc98040c/go.mod h1:0ghtL4RhKBjyWMkHCWosp2aHSEXShQeQMLQ0qBCytfU=
|
||||
github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug=
|
||||
@@ -80,10 +83,21 @@ github.com/go-chi/httprate v0.15.0 h1:j54xcWV9KGmPf/X4H32/aTH+wBlrvxL7P+SdnRqxh5
|
||||
github.com/go-chi/httprate v0.15.0/go.mod h1:rzGHhVrsBn3IMLYDOZQsSU4fJNWcjui4fWKJcCId1R4=
|
||||
github.com/go-logfmt/logfmt v0.6.0 h1:wGYYu3uicYdqXVgoYbvnkrPVXkuLM1p1ifugDMEdRi4=
|
||||
github.com/go-logfmt/logfmt v0.6.0/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
|
||||
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
@@ -138,10 +152,12 @@ github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9Z
|
||||
github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA=
|
||||
github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg=
|
||||
github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is=
|
||||
github.com/riandyrn/otelchi v0.12.2 h1:6QhGv0LVw/dwjtPd12mnNrl0oEQF4ZAlmHcnlTYbeAg=
|
||||
github.com/riandyrn/otelchi v0.12.2/go.mod h1:weZZeUJURvtCcbWsdb7Y6F8KFZGedJlSrgUjq9VirV8=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ=
|
||||
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
@@ -155,34 +171,60 @@ github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
|
||||
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
|
||||
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
|
||||
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
|
||||
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0 h1:88Y4s2C8oTui1LGM6bTWkw0ICGcOLCAI5l6zsD1j20k=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.43.0/go.mod h1:Vl1/iaggsuRlrHf/hfPJPvVag77kKyvrLeD10kpMl+A=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0 h1:3iZJKlCZufyRzPzlQhUIWVmfltrXuGyfjREgGP3UUjc=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.43.0/go.mod h1:/G+nUPfhq2e+qiXMGxMwumDrP5jtzU+mWN7/sjT2rak=
|
||||
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
|
||||
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
|
||||
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
|
||||
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
|
||||
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
|
||||
go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
|
||||
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI=
|
||||
go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU=
|
||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
||||
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
|
||||
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
|
||||
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
|
||||
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
|
||||
golang.org/x/net v0.0.0-20210520170846-37e1c6afe023/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
|
||||
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
|
||||
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
|
||||
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
|
||||
golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
|
||||
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
|
||||
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
||||
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc=
|
||||
google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9 h1:VPWxll4HlMw1Vs/qXtN7BvhZqsS9cdAittCNvVENElA=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:7QBABkRtR8z+TEnmXTqIqwJLlzrZKVfAUm7tY3yGv0M=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9 h1:m8qni9SQFH0tJc1X0vmnpw/0t+AImlSvp30sEupozUg=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260401024825-9d38bb4040a9/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
|
||||
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM=
|
||||
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
|
||||
32
pkg/observability/tracing.go
Normal file
32
pkg/observability/tracing.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package observability
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"go.opentelemetry.io/otel"
|
||||
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp"
|
||||
"go.opentelemetry.io/otel/propagation"
|
||||
"go.opentelemetry.io/otel/sdk/resource"
|
||||
sdktrace "go.opentelemetry.io/otel/sdk/trace"
|
||||
|
||||
semconv "go.opentelemetry.io/otel/semconv/v1.26.0"
|
||||
)
|
||||
|
||||
func InitTracing(ctx context.Context, serviceName, endpoint string) (func(context.Context) error, error) {
|
||||
exp, err := otlptracehttp.New(ctx, otlptracehttp.WithEndpoint(endpoint), otlptracehttp.WithInsecure())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
res, err := resource.New(ctx, resource.WithAttributes(semconv.ServiceName(serviceName)),)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tp := sdktrace.NewTracerProvider(sdktrace.WithBatcher(exp, sdktrace.WithBatchTimeout(5*time.Second)), sdktrace.WithResource(res))
|
||||
|
||||
otel.SetTracerProvider(tp)
|
||||
otel.SetTextMapPropagator(propagation.TraceContext{})
|
||||
return tp.Shutdown, nil
|
||||
}
|
||||
67
scripts/loadtest.sh
Executable file
67
scripts/loadtest.sh
Executable file
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
BASE_URL="${1:-https://api.sshops.uk}"
|
||||
WORKERS=10
|
||||
|
||||
echo "load test → $BASE_URL"
|
||||
echo "tail metrics: watch 'curl -s $BASE_URL/metrics | grep http_requests_total | grep -v _bucket'"
|
||||
echo
|
||||
|
||||
hit() {
|
||||
local method="$1" path="$2" count="$3" workers="$4"
|
||||
echo "→ $method $path ($count requests, $workers parallel)"
|
||||
seq "$count" | xargs -n1 -P"$workers" -I{} \
|
||||
curl -s -o /dev/null -w "" -X "$method" "$BASE_URL$path" || true
|
||||
echo " done"
|
||||
}
|
||||
|
||||
scenario_warmup() {
|
||||
echo "=== scenario: warmup (gentle, sequential) ==="
|
||||
hit GET /api/v1/health 20 1
|
||||
hit GET /api/v1/ping 20 1
|
||||
hit GET /api/v1/products 20 1
|
||||
echo
|
||||
}
|
||||
|
||||
scenario_sustained() {
|
||||
echo "=== scenario: sustained moderate load (60s) ==="
|
||||
local end=$((SECONDS + 60))
|
||||
while [ $SECONDS -lt $end ]; do
|
||||
hit GET /api/v1/health 50 "$WORKERS"
|
||||
hit GET /api/v1/products 50 "$WORKERS"
|
||||
done
|
||||
echo
|
||||
}
|
||||
|
||||
scenario_burst() {
|
||||
echo "=== scenario: burst (300 reqs, $WORKERS parallel) ==="
|
||||
hit GET /api/v1/products 300 "$WORKERS"
|
||||
echo
|
||||
}
|
||||
|
||||
scenario_mixed_with_errors() {
|
||||
echo "=== scenario: mixed traffic + intentional 404s ==="
|
||||
hit GET /api/v1/health 100 "$WORKERS"
|
||||
hit HEAD /api/v1/health 100 "$WORKERS"
|
||||
hit GET /api/v1/products 100 "$WORKERS"
|
||||
hit GET /api/v1/products/does-not-exist 50 5
|
||||
hit GET /api/v1/this-route-doesnt-exist 50 5
|
||||
echo
|
||||
}
|
||||
|
||||
case "${2:-all}" in
|
||||
warmup) scenario_warmup ;;
|
||||
sustained) scenario_sustained ;;
|
||||
burst) scenario_burst ;;
|
||||
mixed) scenario_mixed_with_errors ;;
|
||||
all)
|
||||
scenario_warmup
|
||||
scenario_burst
|
||||
scenario_mixed_with_errors
|
||||
scenario_sustained
|
||||
;;
|
||||
*) echo "usage: $0 [BASE_URL] [warmup|sustained|burst|mixed|all]"; exit 1 ;;
|
||||
esac
|
||||
|
||||
echo "✓ load test complete — check Grafana dashboard"
|
||||
Reference in New Issue
Block a user