diff --git a/00 b/00 new file mode 100644 index 0000000..16ddc01 --- /dev/null +++ b/00 @@ -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 +} diff --git a/REFERENCE_PATTERNS.md b/REFERENCE_PATTERNS.md deleted file mode 100644 index 057af72..0000000 --- a/REFERENCE_PATTERNS.md +++ /dev/null @@ -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. diff --git a/ROADMAP.md b/ROADMAP.md new file mode 100644 index 0000000..32e72eb --- /dev/null +++ b/ROADMAP.md @@ -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. diff --git a/api/handlers/cart.go b/api/handlers/cart.go index dd6aaa4..f9f32be 100644 --- a/api/handlers/cart.go +++ b/api/handlers/cart.go @@ -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) diff --git a/api/main.go b/api/main.go index 04aaa1b..174532c 100644 --- a/api/main.go +++ b/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) diff --git a/api/routes/routes.go b/api/routes/routes.go index 193d582..39ccf2c 100644 --- a/api/routes/routes.go +++ b/api/routes/routes.go @@ -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) diff --git a/go.mod b/go.mod index f9c42d7..c3fcc61 100644 --- a/go.mod +++ b/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 ) diff --git a/go.sum b/go.sum index 6dad4dd..c037d52 100644 --- a/go.sum +++ b/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= diff --git a/pkg/observability/tracing.go b/pkg/observability/tracing.go new file mode 100644 index 0000000..081ad7b --- /dev/null +++ b/pkg/observability/tracing.go @@ -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 +} diff --git a/scripts/loadtest.sh b/scripts/loadtest.sh new file mode 100755 index 0000000..3ce7362 --- /dev/null +++ b/scripts/loadtest.sh @@ -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"