@decocms/start 6.0.0 → 6.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/MIGRATION_TOOLING_PLAN.md +9 -0
- package/docs/observability.md +20 -10
- package/package.json +1 -1
- package/scripts/generate-invoke.test.ts +83 -56
- package/scripts/generate-invoke.ts +26 -10
- package/src/middleware/observability.test.ts +237 -0
- package/src/middleware/observability.ts +165 -8
- package/src/sdk/cachedLoader.ts +10 -7
- package/src/sdk/logger.test.ts +99 -0
- package/src/sdk/logger.ts +18 -7
- package/src/sdk/observability.ts +18 -0
- package/src/sdk/otel.ts +228 -38
- package/src/sdk/otelHttpTracer.test.ts +422 -0
- package/src/sdk/otelHttpTracer.ts +489 -0
- package/src/sdk/requestContext.ts +46 -0
- package/src/sdk/workerEntry.ts +138 -17
|
@@ -121,6 +121,15 @@ this plan.
|
|
|
121
121
|
| 2026-05-07 | **D6.1 — Cloudflare credentials never leave `deco-start`** | Same-day refinement of D6 after the first central deploy on `baggagio-tanstack` failed with `Secret CLOUDFLARE_API_TOKEN is required, but not provided while calling`. The original D6 design used `secrets: inherit` from the storefront stub and required `CLOUDFLARE_*` to live in the `deco-sites` org, which broke the principle that *the only secrets a storefront repo holds are the secrets that go into wrangler secrets, not the ones used to deploy*. First-pass refinement: the central `deploy.yml` / `preview.yml` / `sync-secrets.yml` jobs declared `environment: production` to try to make `${{ secrets.CLOUDFLARE_* }}` resolve from `decocms/deco-start`'s `production` Environment. **Found broken empirically on 2026-05-07** — the deployment registers in the *caller* repo, not the called workflow's repo, so the environment lookup uses the caller's `production` env (auto-created with no secrets). Superseded by D6.2 the same evening. |
|
|
122
122
|
| 2026-05-07 | **D6.2 — App-mediated dispatch + no per-site registry (supersedes D6 + D6.1)** | After D6.1's `environment:` mechanism was empirically shown not to work cross-repo, the architecture pivoted: a `decocms-deployer` GitHub App is installed on `decocms/deco-start` (`actions:write`) and on each storefront repo (`contents:read`, optionally `pull-requests:write`). The storefront caller stub mints a short-lived App-installation token and calls `gh workflow run deploy.yml --repo decocms/deco-start --ref v3 -f site_owner=… -f site_name=…`. The central workflow runs in `decocms/deco-start`'s context, so `CLOUDFLARE_API_TOKEN` / `CLOUDFLARE_ACCOUNT_ID` are ordinary repo secrets. For runtime `SECRET_*` values, each storefront has a `<site_name>-secrets` GitHub Environment in `decocms/deco-start` (S1 design); `sync-secrets.yml` binds to that environment and pushes to `wrangler secret put`. The per-site registry under `deploy/sites/<repo>.jsonc` was dropped entirely (Pure C): worker name = repo basename by convention; the App being installed on the storefront repo is the deploy authorization gate; rare per-worker derived fields (like AE dataset name) use `$WORKER_*` substitution tokens in the template. Force-rollback is impossible for production deploys because the central workflow ignores caller-supplied `site_sha` and resolves the storefront's current default-branch HEAD itself. See [`deploy/README.md`](./deploy/README.md) for the full trust model. **Operational migrations required by Pure C:** `miess-01-tanstack` repo's worker shifts from `miess-tanstack` to `miess-01-tanstack` (CF-side cutover); `lebiscuit-tanstack` AE dataset shifts from `deco_metrics_lebiscuit` to `deco_metrics_lebiscuit_tanstack` (orphans old data). |
|
|
123
123
|
| 2026-05-07 | **D6.3 — Revert D6/D6.1/D6.2; deploys move to Cloudflare Workers Builds** | The whole D6 family (centralized GitHub Actions reusable workflows + `decocms-deployer` GitHub App + per-storefront GitHub Environments + central `deploy/wrangler-template.jsonc` + `deco-wrangler` CLI + per-site caller stubs) is being **reverted**. Trigger: GitHub Free orgs do not propagate org-level secrets to private repos, which forced the App private key to live as a per-storefront repo secret in every storefront — that key gives the holder the ability to mint installation tokens that can trigger workflows on `decocms/deco-start`, which in turn have the only Cloudflare credentials in the system. Per-repo distribution + rotation of that key across N customer storefronts didn't scale and concentrated blast radius on one credential. **Replacement (chosen, to be detailed in a follow-up D-record once shipped):** [Cloudflare Workers Builds](https://developers.cloudflare.com/workers/ci-cd/builds/) owns the deploy/preview pipelines per-worker. Verified empirically on `baggagio-tanstack` 2026-05-07: a malicious `wrangler.jsonc` `name` field pointing at a different worker (`americanas-tanstack`) is **ignored** by CF Builds — the deploy lands on the connected worker (`baggagio-tanstack`), CF surfaces a warning banner in the dashboard, and CF auto-opens a PR to fix the config (deco-sites/baggagio-tanstack#34). The dashboard repo<->worker connection is the source of truth; the in-repo config is treated as a secondary input. Per-storefront wiring (one CF dashboard click per worker) is acceptable at our scale; revisit when CF's [git-integration enable API](https://github.com/cloudflare/workers-sdk/issues/12058) lands. The `deco-build` CLI (regenerates `wrangler.jsonc` bindings from a central template) and runtime-secrets management remain to be designed in a separate PR. |
|
|
124
|
+
| 2026-05-22 | **D-9 — Stable `request.id` propagation across all observability channels** | The fragmentation surfaced during the May 2026 error triage: tail-worker logs, direct-POST metrics, CF-Destinations spans, and structured `console.log` JSON all carry overlapping information but no shared join key. A single user-visible 5xx required hand-correlating timestamps across four ClickHouse tables. **Decision:** the framework generates a stable `request.id` once at request entry (precedence: inbound `x-request-id` → `cf-ray` → `crypto.randomUUID()`) inside `RequestContext.run`, then stamps it on (a) the root span as the `request.id` attribute, (b) every log line via the logger attribute floor, (c) the response as `X-Request-Id` (read by `deco-otel-tail`), and (d) the metric labels via `extra`. Symmetric for `trace.id` — read from the root span's spanContext, echoed as `X-Trace-Id`. This re-establishes a single join key across all channels: pick any row from any table, filter by `request.id`, and reconstruct the full request lifecycle. **Files:** `RequestContext.requestId` + logger floor + workerEntry response-header echo + tail-worker enrichment. **Captured in Phase 1 of [the observability refinement plan](../../.cursor/plans/observability_refinement_plan_4fa41548.plan.md)**. |
|
|
125
|
+
| 2026-05-22 | **D-10 — Server-side log normalization at the ingest worker, cost-neutral** | CF Destinations wraps every `console.log(JSON.stringify(...))` line into an OTLP LogRecord with the JSON body in `body.stringValue`. Querying by structured fields requires `JSONExtract` everywhere — slow, query-fragile, and tied to whatever the producer happens to embed. **Two design choices considered:** (a) migrate all framework `logger.{info,warn,debug}` calls to direct-POST native OTLP, ditching the CF Destinations sampled path. Substantially more code volume in production direct-POST traffic; bypasses the head sampling that keeps fleet cost bounded. (b) lift the JSON-in-body into native OTLP `LogAttributes` server-side at the `deco-otel-ingest` worker. Same wire volume, same cost. **Decision: option (b).** The ingest worker's `logsToRows` now detects JSON-shaped `body.stringValue`, lifts `level`/`msg`/`trace_id`/`span_id` plus arbitrary keys into native OTLP attributes, reduces `Body` to the human-readable `msg`, and falls back unchanged for non-JSON strings (third-party `console.log`). Dashboards drop `JSONExtractString(Body, 'level') = 'error'` in favor of `SeverityText = 'ERROR'`. **Files:** `stats-lake/ingestion/otel-ingest/src/index.ts`. Phase 4 of the observability refinement plan. |
|
|
126
|
+
| 2026-05-22 | **D-11 — Outcome metrics layer becomes the truth source for "did we serve users today?"** | Earlier metric labels (`method`, `path`, `status`) couldn't answer "5xx rate per route per site" without joining metrics to tail-worker logs. The path label was raw-URL (unbounded cardinality risk); status was opaque (no class bucketing); no cache decision / cache layer; no commerce histogram in the framework (only apps-start sites that bumped to a recent version had it). **Decision:** expand the canonical label set for `http_requests_total` / `http_request_duration_ms` / `http_request_errors_total` to `{ method, route_pattern, status, status_class, outcome?, cache_decision?, cache_layer?, region?, …extra }`. `route_pattern` is the TanStack closed-set pattern (`/_products/$slug/p`); fallback is the normalized path. `status_class` is `2xx`/.../`5xx`/`unknown`. Cache labels lift the existing `X-Cache` / `X-Cache-Profile` headers up to the metric so dashboards answer cache-hit rate per route from the counter alone. Move `commerce_request_duration_ms` declaration into `@decocms/start` so every site emits it as soon as the framework is bumped, regardless of apps-start version (apps register operation strings only). Labels: `{ provider, operation, status_class?, cached? }`. **Files:** `src/middleware/observability.ts` (`statusClassFor`, `RequestMetricLabels`, `CacheLayer`, `recordCommerceMetric`, expanded `recordCacheMetric` signature). Phase 2 of the observability refinement plan. |
|
|
127
|
+
| 2026-05-22 | **D-12 — Direct-POST OTLP trace exporter for framework `deco.*` spans** | Empirical verification (May 2026) confirmed the framework's 10+ `withTracing` calls produced zero rows in `otel_traces`. Root cause: the bridge tracer in `instrumentWorker` delegates to `trace.getTracer(...)` on the `@opentelemetry/api` global. With no `TracerProvider` registered (the common case — CF Workers only auto-installs a provider when `observability.traces.destinations` is set), every framework span is silently discarded. **Decision:** introduce `otelHttpTracer.ts` — a direct-POST OTLP/HTTP trace exporter that mirrors the existing meter + error-log adapters. Same transport: per-isolate buffer, ctx.waitUntil flush, FNV-1a hash sampling at `headSamplingRate` (default 0.01 matches CF Destinations recommendation). Consistent per-trace decision so child spans are kept iff their root is kept. Honors inbound W3C `traceparent` — if the remote parent arrived sampled, every span in that trace is exported regardless of the rate. Wired alongside the existing `@opentelemetry/api` bridge via `configureTracerStack` — CF auto-spans still flow to the CF dashboard, framework spans direct-POST to ClickHouse. Default-on `injectTraceContext` inside `createInstrumentedFetch` was already in place. **Files:** `src/sdk/otelHttpTracer.ts`, `src/sdk/otel.ts` (`configureTracerStack`), `src/sdk/workerEntry.ts` (traceparent parsing). Phase 3 of the observability refinement plan. |
|
|
128
|
+
| 2026-05-22 | **D-13 — Per-site Grafana dashboards + alert rules are auto-provisioned from `dim_sites`** | Hand-built dashboards drift the moment a new site lands: a fleet of 100 sites can't be maintained by a human curator. **Decision:** the canonical observability provisioning lives in [`stats-lake/observability/`](../../../stats-lake/observability/) — a single dashboard template + a single alert-rule template, parameterized by `{{site}}`/`{{team}}`/`{{datasource_uid}}` and rendered once per site by `scripts/provision-dashboards.ts` (reads `dim_sites` joined to `dim_teams`, writes to `dashboards/dist/<team>/<site>.json` + `alerts/dist/<team>/<site>.yaml`). Alerts use **anomaly bands, not thresholds** — current 5-min mean vs 24h rolling mean ± 3σ, fires after 10 minutes outside the band. Same rule set runs on every site, but the baseline is per-site so a noisy storefront doesn't false-positive against a quiet one. Every alert carries a `runbook_url` annotation pointing at [`deco-start/docs/runbooks/`](../docs/runbooks/) — the runbook is part of the alert, not a separate artifact. **Decision points open** (Phase 5 of the refinement plan): alerting venue (Grafana → email, Linear MCP tickets, both, or none for v1). **Files:** `stats-lake/observability/{dashboards,alerts,scripts,README.md}` + `deco-start/docs/runbooks/`. |
|
|
129
|
+
| 2026-05-22 | **D-17 — Alerting venue: none for v1; ship dashboards-only** | The action layer (Phase 5) generates anomaly-band alert rule templates per site, but every alert needs a pager and we don't have an on-call rotation. **Decision:** ship dashboards-only for v1. The alert templates in `stats-lake/observability/alerts/templates/site-rules.yaml` stay versioned so they evolve with the dashboards, but `provision-dashboards.ts` only templates them into `alerts/dist/` when `--with-alerts` is passed, and no Grafana → email / Linear MCP / PagerDuty receiver is wired up. Rejected alternatives: (a) Grafana → email — emails get muted within a week without a triage owner; (b) Linear MCP ticket-per-fire — creates noise during active incidents and you can't triage a ticket while firefighting; (c) both — overkill before we know which storefronts will be noisiest. **Revisit when:** an on-call rotation exists, OR a specific incident class earns dedicated paging (e.g., billing-critical sites that need 24/7 coverage). **Files:** `stats-lake/observability/scripts/provision-dashboards.ts` (`--with-alerts` flag), `stats-lake/observability/README.md`. Phase 5 of the observability refinement plan. |
|
|
130
|
+
| 2026-05-22 | **D-16 — `deco-audit-observability` is warn-by-default; promote to block once the fleet is clean** | The audit (D-14) detects drift in `tail_consumers`, `version_metadata`, `DECO_METRICS`, and `DECO_OTEL_*_ENDPOINT` vars across every storefront wrangler.jsonc. Pre-merge blocking on day one would fail PRs that have nothing to do with observability — storefronts are upgraded over weeks, not all at once. Advisory comments alone get ignored. **Decision:** `--mode warn` (default) annotates findings via `::warning::` GitHub Actions lines but always exits 0, so observability drift surfaces in CI without blocking ship. `--mode block` exits 1 on any `error`-severity finding for use once the fleet has been pulled current. Storefronts opt into `block` per-repo by wiring `--mode block` in their workflow when they've cleared their findings. Includes `--github` flag to emit native annotations. **Files:** `scripts/audit-observability-config.ts` (parseArgs `--mode` / `--github`, main exit policy), test coverage in the matching `.test.ts` (6 new CLI smoke tests via tsx subprocess). Phase 6 of the observability refinement plan. |
|
|
131
|
+
| 2026-05-22 | **D-15 — OTel Collector swap is a documented target state, not a committed milestone** | The current `deco-otel-ingest` Worker is a hand-rolled OTLP/HTTP parser + ClickHouse inserter. It works at ~14M POSTs/month, but each new OTLP protocol revision, each new receiver (gRPC OTLP, Prometheus remote-write), and each new sink would cost us code to write and maintain — code the upstream OTel Collector + `clickhouseexporter` ship as a maintained product. **Decision:** mark the Collector swap as the eventual target state, **with no committed timeline**, and capture the explicit revisit-triggers so we know when to act rather than relying on "we should think about this sometime." The decision is cheap because the ClickHouse schema is **already** the canonical `clickhouseexporter` shape — that was a deliberate design choice in `clickhouse/schema/otel/` so the ingest path stays swappable. Migration when triggered is config-only at the data layer: stand up a Collector in the same CF account, configure `otlphttp`/`otlpgrpc` receivers + `clickhouse/v1` exporter + `transform` processors for the existing PII redaction and JSON-body lift (1:1 from current Worker logic), DNS-cutover the `DECO_OTEL_*_ENDPOINT` vars. **Revisit triggers:** OTLP 2.0 ships, we need gRPC OTLP, we need a non-ClickHouse sink, ingest volume exceeds 100M POSTs/mo, or a hand-rolled parser develops a defect we can't fix quickly. **Files:** [`stats-lake/ingestion/otel-ingest/COLLECTOR_TARGET.md`](../../../stats-lake/ingestion/otel-ingest/COLLECTOR_TARGET.md) holds the full migration runbook + rollback story. Phase 7 of the observability refinement plan; explicitly **optional**. |
|
|
132
|
+
| 2026-05-22 | **D-14 — `deco-audit-observability` covers fleet bindings, not just the `observability` block** | The existing audit only checked the `observability` block (sampling rates, persist, destinations). Phase 1+2+3 made several other wrangler keys load-bearing: `tail_consumers` must list `deco-otel-tail` (Phase 1 enrichment is a no-op without it), `version_metadata` must bind `CF_VERSION_METADATA` (no `service.version` without it = no deploy correlation), `analytics_engine_datasets` must bind `DECO_METRICS` (no AE meter), `vars.DECO_OTEL_{METRICS,TRACES,LOGS}_ENDPOINT` must resolve (direct-POST channels silently no-op otherwise). **Decision:** expand the audit with six new rules under a sibling function `auditFleetBindings` and a composing `auditWranglerConfig`. Severity tuned to the impact: tail consumer + version_metadata are `error` (operational coverage gap); the rest are `warn` (degraded mode, not total failure). Drift is detected today; the matching `--fix` codemod and CI gate hardness (block / warn / advisory) are Phase 6 decision points still open. **Files:** `scripts/audit-observability-config.ts` (`auditFleetBindings`, `auditWranglerConfig`). |
|
|
124
133
|
| 2026-05-19 | **D-8 — Cloudflare Tail Worker (Strategy B) is the canonical 100% error capture mechanism** | At fleet scale (100 sites, 2.5B req/month) head sampling forces a tradeoff: 1% sampling makes the `head_sampling_rate * 5B-event-cap` math work, but 99% of error traces and 99% of error-correlated logs get dropped at the CF Destinations head. The framework already covers framework-emitted errors via the in-Worker direct-POST channel (`DECO_OTEL_LOGS_ENDPOINT`) — that's 100% of `logger.error(...)` regardless of `head_sampling_rate`. But three structural gaps remain that *no* in-Worker code can close from inside its own request handler: (a) uncaught throws (the worker isolate is already unwinding when the throw bubbles out of `instrumentWorker`), (b) `exceededCpu` / `exceededMemory` outcomes (the runtime kills the producer before any in-Worker code can run), (c) raw `console.error(...)` from third-party SDKs that bypass the framework logger. **Decision:** introduce [`deco-otel-tail`](https://github.com/decocms/stats-lake/tree/main/ingestion/otel-tail) — a Cloudflare Tail Worker in `stats-lake/ingestion/otel-tail/`. CF invokes it on every execution of any producer worker that lists it under `tail_consumers` (`wrangler.jsonc`). The handler filters TraceItems down to the interesting subset (`outcome !== "ok" \|\| exceptions.length > 0 \|\| logs.some(l => l.level === "error")`), translates each to OTLP LogRecords (one per exception, one per `error`-level log line, plus a synthetic LogRecord for non-ok outcomes that didn't surface either), and forwards them to `deco-otel-ingest` via an in-account service binding (no public hop). Rows land in `otel_logs` with `Attributes['_source'] = 'tail-worker'` so dashboards can split tail-captured errors from direct-POST + CF-Destinations errors. **Rejected alternatives:** (1) **Codemod + lint to enforce `logger.error` calls** — structural coverage gap; can't catch uncaught throws or 1101s by definition, and a lint can't enforce calls inside third-party code. (2) **Logpush + ingest pipeline** — bypassed because Logpush isn't OTLP-shaped and the pricing curve loses to tail-worker at our scale. (3) **CF dashboard log retention only** — no fan-out to ClickHouse, no fleet-wide query surface. (4) **DO-buffered tail-on-error** — ~$8K/mo at fleet scale per the cost model in `docs/observability.md`. **Coverage matrix lives in [`docs/observability.md`](./docs/observability.md) → "Error capture — three-channel model".** Producer-side wiring is one line per `wrangler.jsonc`: `tail_consumers: [{ service: "deco-otel-tail" }]`. **Operational dependency:** the tail worker MUST be deployed to the same Cloudflare account as `deco-otel-ingest` (currently `c95fc4cec7fc52453228d9db170c372c`) so the `[[services]]` binding resolves. If `deco-otel-ingest` ever moves accounts, the service binding collapses to a public HTTPS POST and the model needs revisiting. **Agent behaviour:** when designing error capture for new Worker-deployed code, default to Strategy B for the long tail; don't reach for codemod/lint enforcement unless there's a specific code-quality concern beyond capture. |
|
|
125
134
|
|
|
126
135
|
The full text of the constitutional rule (loaded into every agent
|
package/docs/observability.md
CHANGED
|
@@ -71,17 +71,24 @@ This makes it possible to filter traces by cache decision directly in ClickStack
|
|
|
71
71
|
|
|
72
72
|
## What's measured
|
|
73
73
|
|
|
74
|
-
| Metric
|
|
75
|
-
|
|
|
76
|
-
| `http_requests_total`
|
|
77
|
-
| `http_request_duration_ms`
|
|
78
|
-
| `http_request_errors_total`
|
|
79
|
-
| `cache_hit_total`
|
|
80
|
-
| `cache_miss_total`
|
|
81
|
-
| `
|
|
74
|
+
| Metric | Type | Source | Labels (canonical, Phase 2 / D-11) |
|
|
75
|
+
| ------------------------------- | --------- | ----------------------------------- | ----------------------------------------------------------------------------------------------- |
|
|
76
|
+
| `http_requests_total` | counter | `workerEntry` | `method`, `route_pattern`, `status`, `status_class`, `outcome?`, `cache_decision?`, `cache_layer?`, `region?` |
|
|
77
|
+
| `http_request_duration_ms` | histogram | `workerEntry` | same as `http_requests_total` |
|
|
78
|
+
| `http_request_errors_total` | counter | `workerEntry` (status >= 500) | same as `http_requests_total` |
|
|
79
|
+
| `cache_hit_total` | counter | edge cache decision | `profile`, `decision`, `layer` (`edge` \| `cachedLoader` \| `vtex-swr`) |
|
|
80
|
+
| `cache_miss_total` | counter | edge cache decision | `profile`, `decision`, `layer` |
|
|
81
|
+
| `commerce_request_duration_ms` | histogram | commerce clients (vtex/shopify/…) | `provider`, `operation`, `status_class?`, `cached?` |
|
|
82
|
+
| `resolve_duration_ms` | histogram | `resolveDecoPage` | — |
|
|
82
83
|
|
|
83
84
|
`decision` values mirror the `X-Cache` response header: `HIT`, `STALE-HIT`, `STALE-ERROR`, `MISS`, `BYPASS`.
|
|
84
85
|
|
|
86
|
+
`route_pattern` is the TanStack route pattern (e.g. `/_products/$slug/p`) rather than the raw URL path — bounded cardinality, joinable to the route table. Callers that don't supply one get a normalized path with dynamic segments collapsed (`/products/:slug/p`).
|
|
87
|
+
|
|
88
|
+
`status_class` is the canonical `2xx`/.../`5xx`/`unknown` bucket. Dashboards aggregate by `status_class` for SLO panels and by `status` for incident drill-down.
|
|
89
|
+
|
|
90
|
+
`commerce_request_duration_ms` owned by the framework (Phase 2 / D-11) so every site emits it as soon as `@decocms/start` is bumped, regardless of `@decocms/apps` version. Apps register operation strings via `recordCommerceMetric`; the framework owns the cardinality contract.
|
|
91
|
+
|
|
85
92
|
### Metrics: AE vs OTLP (the two-meter split)
|
|
86
93
|
|
|
87
94
|
`instrumentWorker` plugs **up to two meters in parallel**, composed via `createCompositeMeter`:
|
|
@@ -181,8 +188,9 @@ The direct-POST channels are wired automatically when the relevant env vars reso
|
|
|
181
188
|
| ---------------------------------- | -------------------- | ---------------------- | ---------------------------------------------------- |
|
|
182
189
|
| `DECO_OTEL_METRICS_ENDPOINT` | OTLP metrics POST | `""` (unset) | OTLP meter is not created; AE-only metrics |
|
|
183
190
|
| `DECO_OTEL_LOGS_ENDPOINT` | OTLP error-log POST | `""` (unset) | Error logs ride CF Destinations only (head-sampled) |
|
|
191
|
+
| `DECO_OTEL_TRACES_ENDPOINT` | OTLP traces POST | `""` (unset) | Framework `deco.*` spans drop unless CF Traces is on |
|
|
184
192
|
|
|
185
|
-
|
|
193
|
+
All three are opt-out via `OtelOptions.otlpMetricsEnabled: false` / `otlpErrorLogsEnabled: false` / `otlpTracesEnabled: false` if you need to disable them at boot for a specific environment without changing the env vars. Traces honor `OtelOptions.otlpTracesSamplingRate` (default `0.01` to match CF Destinations) — sampling decisions are consistent per trace (`FNV-1a` hash of `trace_id`), so child spans are kept iff their root is kept. Remote parents that arrive sampled (`traceparent` flags `01`) override the rate and are always exported.
|
|
186
194
|
|
|
187
195
|
## Log shape (and how to query it)
|
|
188
196
|
|
|
@@ -213,7 +221,9 @@ In the ClickStack UI you can also filter logs panel by `trace_id` directly — p
|
|
|
213
221
|
|
|
214
222
|
## Outbound trace propagation
|
|
215
223
|
|
|
216
|
-
For
|
|
224
|
+
For commerce clients (VTEX, Shopify), `createInstrumentedFetch` injects the W3C `traceparent` header by default. To opt out for a specific endpoint that rejects unknown headers, pass `injectTraceparent: false`.
|
|
225
|
+
|
|
226
|
+
For any other outbound `fetch` issued during a request, inject a `traceparent` header manually so upstream services that participate in OTel can join your trace:
|
|
217
227
|
|
|
218
228
|
```ts
|
|
219
229
|
import { injectTraceContext } from "@decocms/start/sdk/observability";
|
package/package.json
CHANGED
|
@@ -21,18 +21,20 @@ import { spawnSync } from "node:child_process";
|
|
|
21
21
|
import * as fs from "node:fs";
|
|
22
22
|
import * as os from "node:os";
|
|
23
23
|
import * as path from "node:path";
|
|
24
|
-
import {
|
|
24
|
+
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
|
25
25
|
|
|
26
26
|
const GENERATOR = path.resolve(__dirname, "generate-invoke.ts");
|
|
27
27
|
|
|
28
28
|
const FIXTURE_INVOKE_TS = `\
|
|
29
29
|
import { createInvokeFn } from "@decocms/start/sdk/createInvoke";
|
|
30
30
|
import { getOrCreateCart, simulateCart } from "./actions/checkout";
|
|
31
|
+
import { createSession } from "./actions/session";
|
|
31
32
|
import type { OrderForm } from "./types";
|
|
32
33
|
|
|
33
34
|
export const invoke = {
|
|
34
35
|
vtex: {
|
|
35
36
|
actions: {
|
|
37
|
+
// Direct pass-through wrapper — most common shape.
|
|
36
38
|
getOrCreateCart: createInvokeFn(
|
|
37
39
|
(data: { orderFormId?: string }) => getOrCreateCart(data),
|
|
38
40
|
) as unknown as (ctx: { data: { orderFormId?: string } }) => Promise<OrderForm>,
|
|
@@ -40,6 +42,13 @@ export const invoke = {
|
|
|
40
42
|
simulateCart: createInvokeFn(
|
|
41
43
|
(data: { postalCode: string }) => simulateCart(data),
|
|
42
44
|
),
|
|
45
|
+
|
|
46
|
+
// Adapting wrapper — payload gets wrapped into the action's
|
|
47
|
+
// props shape. The generator MUST preserve this wrap or the
|
|
48
|
+
// action call typechecks against the wrong props type.
|
|
49
|
+
createSession: createInvokeFn(
|
|
50
|
+
(data: Record<string, any>) => createSession({ data }),
|
|
51
|
+
),
|
|
43
52
|
},
|
|
44
53
|
},
|
|
45
54
|
} as const;
|
|
@@ -52,14 +61,25 @@ const FIXTURE_ACTIONS_CHECKOUT_TS = `\
|
|
|
52
61
|
export async function getOrCreateCart(_data: any): Promise<any> { return null; }
|
|
53
62
|
export async function simulateCart(_data: any): Promise<any> { return null; }
|
|
54
63
|
`;
|
|
64
|
+
const FIXTURE_ACTIONS_SESSION_TS = `\
|
|
65
|
+
export interface CreateSessionProps { data: Record<string, any>; }
|
|
66
|
+
export async function createSession(_props: CreateSessionProps): Promise<any> { return null; }
|
|
67
|
+
`;
|
|
55
68
|
const FIXTURE_TYPES_TS = `export type OrderForm = unknown;\n`;
|
|
56
69
|
|
|
57
70
|
describe("generate-invoke.ts — output shape", () => {
|
|
71
|
+
// The fixture is read-only across tests: every assertion runs against
|
|
72
|
+
// the same generated `invoke.gen.ts`. Running the generator once in
|
|
73
|
+
// `beforeAll` keeps the test fast (each `npx tsx` spawn is ~3-5s) and
|
|
74
|
+
// sidesteps the vitest 5s default per-test timeout that this suite was
|
|
75
|
+
// tipping over once we grew the fixture.
|
|
58
76
|
let appsDir: string;
|
|
59
77
|
let siteDir: string;
|
|
60
78
|
let outFile: string;
|
|
79
|
+
let generatedOutput: string;
|
|
80
|
+
let generatorStatus: number | null;
|
|
61
81
|
|
|
62
|
-
|
|
82
|
+
beforeAll(() => {
|
|
63
83
|
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "gen-invoke-"));
|
|
64
84
|
appsDir = path.join(tmp, "apps");
|
|
65
85
|
siteDir = path.join(tmp, "site");
|
|
@@ -70,11 +90,23 @@ describe("generate-invoke.ts — output shape", () => {
|
|
|
70
90
|
path.join(appsDir, "vtex", "actions", "checkout.ts"),
|
|
71
91
|
FIXTURE_ACTIONS_CHECKOUT_TS,
|
|
72
92
|
);
|
|
93
|
+
fs.writeFileSync(
|
|
94
|
+
path.join(appsDir, "vtex", "actions", "session.ts"),
|
|
95
|
+
FIXTURE_ACTIONS_SESSION_TS,
|
|
96
|
+
);
|
|
73
97
|
fs.writeFileSync(path.join(appsDir, "vtex", "types.ts"), FIXTURE_TYPES_TS);
|
|
74
98
|
outFile = path.join(siteDir, "src", "server", "invoke.gen.ts");
|
|
75
|
-
});
|
|
76
99
|
|
|
77
|
-
|
|
100
|
+
const result = spawnSync(
|
|
101
|
+
"npx",
|
|
102
|
+
["tsx", GENERATOR, "--apps-dir", appsDir, "--out-file", outFile],
|
|
103
|
+
{ cwd: siteDir, encoding: "utf8" },
|
|
104
|
+
);
|
|
105
|
+
generatorStatus = result.status;
|
|
106
|
+
generatedOutput = fs.readFileSync(outFile, "utf8");
|
|
107
|
+
}, 30_000);
|
|
108
|
+
|
|
109
|
+
afterAll(() => {
|
|
78
110
|
// Best-effort cleanup; tmp dirs leak otherwise.
|
|
79
111
|
try {
|
|
80
112
|
fs.rmSync(path.dirname(appsDir), { recursive: true, force: true });
|
|
@@ -83,86 +115,81 @@ describe("generate-invoke.ts — output shape", () => {
|
|
|
83
115
|
}
|
|
84
116
|
});
|
|
85
117
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
GENERATOR,
|
|
92
|
-
"--apps-dir",
|
|
93
|
-
appsDir,
|
|
94
|
-
"--out-file",
|
|
95
|
-
outFile,
|
|
96
|
-
],
|
|
97
|
-
{
|
|
98
|
-
cwd: siteDir,
|
|
99
|
-
encoding: "utf8",
|
|
100
|
-
},
|
|
101
|
-
);
|
|
102
|
-
return {
|
|
103
|
-
stdout: result.stdout ?? "",
|
|
104
|
-
stderr: result.stderr ?? "",
|
|
105
|
-
status: result.status,
|
|
106
|
-
};
|
|
107
|
-
}
|
|
118
|
+
it("runs to completion against a minimal fixture", () => {
|
|
119
|
+
// Sanity check — every subsequent assertion is wasted if the
|
|
120
|
+
// generator process bombed.
|
|
121
|
+
expect(generatorStatus).toBe(0);
|
|
122
|
+
});
|
|
108
123
|
|
|
109
124
|
it("imports the framework helpers needed for Set-Cookie propagation", () => {
|
|
110
|
-
const { status } = runGenerator();
|
|
111
|
-
expect(status).toBe(0);
|
|
112
|
-
|
|
113
|
-
const out = fs.readFileSync(outFile, "utf8");
|
|
114
125
|
// Both imports must be present — without them, forwardResponseCookies
|
|
115
126
|
// doesn't compile in the site, and the regression we're fixing
|
|
116
127
|
// resurfaces silently when someone deletes one of them.
|
|
117
|
-
expect(
|
|
118
|
-
expect(
|
|
119
|
-
expect(
|
|
128
|
+
expect(generatedOutput).toContain('from "@tanstack/react-start/server"');
|
|
129
|
+
expect(generatedOutput).toMatch(/getResponseHeaders\s*,?\s*\n?\s*setResponseHeader/);
|
|
130
|
+
expect(generatedOutput).toContain(
|
|
131
|
+
'import { RequestContext } from "@decocms/start/sdk/requestContext"',
|
|
132
|
+
);
|
|
120
133
|
});
|
|
121
134
|
|
|
122
135
|
it("emits the forwardResponseCookies helper exactly once", () => {
|
|
123
|
-
|
|
124
|
-
const out = fs.readFileSync(outFile, "utf8");
|
|
125
|
-
// Match the declaration, not call sites. There's only one helper.
|
|
126
|
-
const declMatches = out.match(/function forwardResponseCookies\(\)/g);
|
|
136
|
+
const declMatches = generatedOutput.match(/function forwardResponseCookies\(\)/g);
|
|
127
137
|
expect(declMatches).toHaveLength(1);
|
|
128
138
|
|
|
129
139
|
// The helper must read from RequestContext.responseHeaders and call
|
|
130
140
|
// setResponseHeader. Locking the bridge ends-to-ends.
|
|
131
|
-
expect(
|
|
132
|
-
expect(
|
|
133
|
-
expect(
|
|
141
|
+
expect(generatedOutput).toContain("RequestContext.current");
|
|
142
|
+
expect(generatedOutput).toContain("ctx.responseHeaders.getSetCookie");
|
|
143
|
+
expect(generatedOutput).toContain('setResponseHeader("set-cookie"');
|
|
134
144
|
});
|
|
135
145
|
|
|
136
146
|
it("calls forwardResponseCookies() inside every generated handler", () => {
|
|
137
|
-
runGenerator();
|
|
138
|
-
const out = fs.readFileSync(outFile, "utf8");
|
|
139
|
-
|
|
140
147
|
// Each .handler(async ({ data }) ...) block produced by the
|
|
141
148
|
// generator must contain a `forwardResponseCookies()` call. We
|
|
142
149
|
// count handlers vs. call sites — excluding the declaration site
|
|
143
150
|
// (`function forwardResponseCookies()`), which also matches the
|
|
144
151
|
// bare `forwardResponseCookies()` token.
|
|
145
|
-
const handlerCount = (
|
|
146
|
-
const allOccurrences = (
|
|
147
|
-
const declSites = (
|
|
152
|
+
const handlerCount = (generatedOutput.match(/\.handler\(async \(\{ data \}\)/g) ?? []).length;
|
|
153
|
+
const allOccurrences = (generatedOutput.match(/\bforwardResponseCookies\(\)/g) ?? []).length;
|
|
154
|
+
const declSites = (generatedOutput.match(/function\s+forwardResponseCookies\(\)/g) ?? [])
|
|
155
|
+
.length;
|
|
148
156
|
const callSites = allOccurrences - declSites;
|
|
149
157
|
|
|
150
|
-
expect(handlerCount).toBe(
|
|
158
|
+
expect(handlerCount).toBe(3);
|
|
151
159
|
expect(declSites).toBe(1);
|
|
152
|
-
expect(callSites).toBe(
|
|
160
|
+
expect(callSites).toBe(3);
|
|
153
161
|
});
|
|
154
162
|
|
|
155
163
|
it("calls forwardResponseCookies AFTER the action awaits (so RequestContext is populated)", () => {
|
|
156
|
-
runGenerator();
|
|
157
|
-
const out = fs.readFileSync(outFile, "utf8");
|
|
158
|
-
|
|
159
164
|
// The action must complete (await result) before we read the
|
|
160
165
|
// captured Set-Cookies — otherwise we'd forward an empty set
|
|
161
|
-
// every time.
|
|
162
|
-
//
|
|
166
|
+
// every time. The expression body after `await` can be any call
|
|
167
|
+
// shape the wrapper produces (`fn(data)` or `fn({ data })`),
|
|
168
|
+
// so we match across the whole expression up to the semicolon.
|
|
163
169
|
const pattern =
|
|
164
|
-
/const result = await
|
|
165
|
-
const orderedCalls =
|
|
166
|
-
expect(orderedCalls.length).toBe(
|
|
170
|
+
/const result = await [^;]+;\s+forwardResponseCookies\(\);\s+return (?:unwrapResult\(result\)|result);/g;
|
|
171
|
+
const orderedCalls = generatedOutput.match(pattern) ?? [];
|
|
172
|
+
expect(orderedCalls.length).toBe(3);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("preserves adapting wrappers verbatim (does not collapse to actionFn(data))", () => {
|
|
176
|
+
// Regression for the createSession-shape wrapper: the generator
|
|
177
|
+
// previously hard-coded `${importedFn}(data)` in every handler,
|
|
178
|
+
// silently dropping the wrap that wrappers like
|
|
179
|
+
// createSession: createInvokeFn((data) => createSession({ data }))
|
|
180
|
+
// perform to bridge the external invoke shape to the internal
|
|
181
|
+
// action shape. Sites that regenerated against this hit
|
|
182
|
+
// `TS2345: Property 'data' is missing in type '{ [x: string]: any; }'`
|
|
183
|
+
// at the regenerated call site of every adapting wrapper.
|
|
184
|
+
expect(generatedOutput).toContain("const result = await createSession({ data });");
|
|
185
|
+
// And the broken shortcut must NOT appear for this action.
|
|
186
|
+
expect(generatedOutput).not.toMatch(/const result = await createSession\(data\);/);
|
|
187
|
+
|
|
188
|
+
// Direct pass-through wrappers are unaffected: their body is
|
|
189
|
+
// already `actionFn(data)`, so emitting verbatim produces the same
|
|
190
|
+
// output that the previous shortcut produced. Lock that too — a
|
|
191
|
+
// future refactor that breaks pass-throughs would be just as bad.
|
|
192
|
+
expect(generatedOutput).toContain("const result = await getOrCreateCart(data);");
|
|
193
|
+
expect(generatedOutput).toContain("const result = await simulateCart(data);");
|
|
167
194
|
});
|
|
168
195
|
});
|
|
@@ -375,23 +375,39 @@ for (const action of actions) {
|
|
|
375
375
|
const varName = `$${action.name}`;
|
|
376
376
|
|
|
377
377
|
if (action.importedFn) {
|
|
378
|
-
//
|
|
379
|
-
//
|
|
380
|
-
//
|
|
378
|
+
// Emit the wrapper body verbatim. The arrow function in
|
|
379
|
+
// @decocms/apps/vtex/invoke.ts is the contract that maps the external
|
|
380
|
+
// invoke shape (what storefront callers send) to the internal action
|
|
381
|
+
// shape (what vtex/actions/* expects). Most wrappers are direct
|
|
382
|
+
// pass-throughs (`actionFn(data)`) but some adapt the payload
|
|
383
|
+
// (e.g. `createSession({ data })` wraps a flat session payload into
|
|
384
|
+
// CreateSessionProps). Hard-coding `${importedFn}(data)` silently
|
|
385
|
+
// dropped the wrap, producing typecheck errors at the call site of
|
|
386
|
+
// every adapting wrapper.
|
|
387
|
+
//
|
|
388
|
+
// Invariant: when action.importedFn is set, action.callBody was
|
|
389
|
+
// non-empty and contained `${importedFn}(` (that's how importedFn was
|
|
390
|
+
// discovered in the first place — see the importMap scan above).
|
|
391
|
+
// Block bodies clear callBody to "", which forces importedFn to ""
|
|
392
|
+
// and routes to the stub branch below.
|
|
393
|
+
//
|
|
394
|
+
// The arrow's parameter is `data` by convention across every wrapper
|
|
395
|
+
// in vtex/invoke.ts, and the generated handler destructures `{ data }`
|
|
396
|
+
// from the validator output, so callBody's `data` references resolve
|
|
397
|
+
// to the handler's local `data` without any rename.
|
|
381
398
|
//
|
|
382
399
|
// forwardResponseCookies() runs AFTER the action awaits, so any
|
|
383
400
|
// Set-Cookie that vtexFetchWithCookies captured onto
|
|
384
401
|
// RequestContext.responseHeaders gets promoted to the actual HTTP
|
|
385
|
-
// response. Safe no-op when the action didn't touch
|
|
386
|
-
//
|
|
387
|
-
//
|
|
388
|
-
//
|
|
389
|
-
// start propagating cookies.
|
|
402
|
+
// response. Safe no-op when the action didn't touch responseHeaders
|
|
403
|
+
// (e.g. masterData reads), so it's applied unconditionally — the
|
|
404
|
+
// alternative (a static allow-list of cookie-bearing actions)
|
|
405
|
+
// silently misses any new actions that start propagating cookies.
|
|
390
406
|
if (action.unwrap) {
|
|
391
407
|
out += `\nconst ${varName} = createServerFn({ method: "POST" })
|
|
392
408
|
.inputValidator((data: ${action.inputType}) => data)
|
|
393
409
|
.handler(async ({ data }): Promise<any> => {
|
|
394
|
-
const result = await ${action.
|
|
410
|
+
const result = await ${action.callBody};
|
|
395
411
|
forwardResponseCookies();
|
|
396
412
|
return unwrapResult(result);
|
|
397
413
|
});\n`;
|
|
@@ -399,7 +415,7 @@ for (const action of actions) {
|
|
|
399
415
|
out += `\nconst ${varName} = createServerFn({ method: "POST" })
|
|
400
416
|
.inputValidator((data: ${action.inputType}) => data)
|
|
401
417
|
.handler(async ({ data }): Promise<any> => {
|
|
402
|
-
const result = await ${action.
|
|
418
|
+
const result = await ${action.callBody};
|
|
403
419
|
forwardResponseCookies();
|
|
404
420
|
return result;
|
|
405
421
|
});\n`;
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Phase 2 (D-11) coverage for the metric surface — canonical label set,
|
|
3
|
+
* cache_layer, commerce_request_duration_ms. The Phase 1 logger/trace
|
|
4
|
+
* tests live under `src/sdk/logger.test.ts` and `src/sdk/otel.test.ts`;
|
|
5
|
+
* this file focuses on the middleware-level helpers.
|
|
6
|
+
*/
|
|
7
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
8
|
+
import {
|
|
9
|
+
configureMeter,
|
|
10
|
+
type MeterAdapter,
|
|
11
|
+
MetricNames,
|
|
12
|
+
recordCacheMetric,
|
|
13
|
+
recordCommerceMetric,
|
|
14
|
+
recordRequestMetric,
|
|
15
|
+
statusClassFor,
|
|
16
|
+
} from "./observability";
|
|
17
|
+
|
|
18
|
+
interface Counter {
|
|
19
|
+
name: string;
|
|
20
|
+
value: number;
|
|
21
|
+
labels?: Record<string, unknown>;
|
|
22
|
+
}
|
|
23
|
+
interface Histogram {
|
|
24
|
+
name: string;
|
|
25
|
+
value: number;
|
|
26
|
+
labels?: Record<string, unknown>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function captureMeter(): {
|
|
30
|
+
adapter: MeterAdapter;
|
|
31
|
+
counters: Counter[];
|
|
32
|
+
histograms: Histogram[];
|
|
33
|
+
} {
|
|
34
|
+
const counters: Counter[] = [];
|
|
35
|
+
const histograms: Histogram[] = [];
|
|
36
|
+
const adapter: MeterAdapter = {
|
|
37
|
+
counterInc(name, value, labels) {
|
|
38
|
+
counters.push({ name, value: value ?? 1, labels });
|
|
39
|
+
},
|
|
40
|
+
histogramRecord(name, value, labels) {
|
|
41
|
+
histograms.push({ name, value, labels });
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
return { adapter, counters, histograms };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe("statusClassFor", () => {
|
|
48
|
+
it("maps 2xx / 3xx / 4xx / 5xx to canonical class labels", () => {
|
|
49
|
+
expect(statusClassFor(200)).toBe("2xx");
|
|
50
|
+
expect(statusClassFor(204)).toBe("2xx");
|
|
51
|
+
expect(statusClassFor(301)).toBe("3xx");
|
|
52
|
+
expect(statusClassFor(404)).toBe("4xx");
|
|
53
|
+
expect(statusClassFor(500)).toBe("5xx");
|
|
54
|
+
expect(statusClassFor(503)).toBe("5xx");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("returns 'unknown' for out-of-range / NaN / non-numeric inputs", () => {
|
|
58
|
+
expect(statusClassFor(-1)).toBe("unknown");
|
|
59
|
+
expect(statusClassFor(99)).toBe("unknown");
|
|
60
|
+
expect(statusClassFor(600)).toBe("unknown");
|
|
61
|
+
expect(statusClassFor(Number.NaN)).toBe("unknown");
|
|
62
|
+
expect(statusClassFor(Infinity)).toBe("unknown");
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
describe("recordRequestMetric — canonical labels (D-11)", () => {
|
|
67
|
+
afterEach(() => {
|
|
68
|
+
// Reset meter so other tests start clean.
|
|
69
|
+
configureMeter({ counterInc: () => {} });
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("stamps method + route_pattern + status + status_class by default", () => {
|
|
73
|
+
const { adapter, counters, histograms } = captureMeter();
|
|
74
|
+
configureMeter(adapter);
|
|
75
|
+
|
|
76
|
+
recordRequestMetric("GET", "/products/abc123/p", 200, 42);
|
|
77
|
+
|
|
78
|
+
expect(counters).toHaveLength(1);
|
|
79
|
+
expect(counters[0]?.name).toBe(MetricNames.HTTP_REQUESTS_TOTAL);
|
|
80
|
+
expect(counters[0]?.labels).toMatchObject({
|
|
81
|
+
method: "GET",
|
|
82
|
+
// Default normalization: dynamic segments collapsed.
|
|
83
|
+
route_pattern: "/products/:slug/p",
|
|
84
|
+
status: 200,
|
|
85
|
+
status_class: "2xx",
|
|
86
|
+
});
|
|
87
|
+
expect(histograms).toHaveLength(1);
|
|
88
|
+
expect(histograms[0]?.name).toBe(MetricNames.HTTP_REQUEST_DURATION_MS);
|
|
89
|
+
expect(histograms[0]?.value).toBe(42);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("prefers caller-supplied route_pattern over normalized path", () => {
|
|
93
|
+
const { adapter, counters } = captureMeter();
|
|
94
|
+
configureMeter(adapter);
|
|
95
|
+
|
|
96
|
+
recordRequestMetric("GET", "/anything/random/123", 200, 5, {
|
|
97
|
+
route_pattern: "/_products/$slug/p",
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
expect(counters[0]?.labels?.route_pattern).toBe("/_products/$slug/p");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("emits http_request_errors_total on 5xx", () => {
|
|
104
|
+
const { adapter, counters } = captureMeter();
|
|
105
|
+
configureMeter(adapter);
|
|
106
|
+
|
|
107
|
+
recordRequestMetric("POST", "/checkout", 503, 120);
|
|
108
|
+
|
|
109
|
+
const errCounter = counters.find((c) => c.name === MetricNames.HTTP_REQUEST_ERRORS);
|
|
110
|
+
expect(errCounter).toBeDefined();
|
|
111
|
+
expect(errCounter?.labels?.status_class).toBe("5xx");
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("propagates optional labels (outcome, cache_decision, cache_layer, region, extra)", () => {
|
|
115
|
+
const { adapter, counters } = captureMeter();
|
|
116
|
+
configureMeter(adapter);
|
|
117
|
+
|
|
118
|
+
recordRequestMetric("GET", "/", 200, 10, {
|
|
119
|
+
outcome: "ok",
|
|
120
|
+
cache_decision: "STALE-HIT",
|
|
121
|
+
cache_layer: "edge",
|
|
122
|
+
region: "GRU",
|
|
123
|
+
extra: { ab_variant: "B" },
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
expect(counters[0]?.labels).toMatchObject({
|
|
127
|
+
outcome: "ok",
|
|
128
|
+
cache_decision: "STALE-HIT",
|
|
129
|
+
cache_layer: "edge",
|
|
130
|
+
region: "GRU",
|
|
131
|
+
ab_variant: "B",
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
it("is a no-op when no meter is configured", () => {
|
|
136
|
+
// We can't easily prove a no-op other than verifying no throw —
|
|
137
|
+
// safer than calling configureMeter(null), which would mask real
|
|
138
|
+
// bugs. The previous test's `afterEach` reset already gives us a
|
|
139
|
+
// bare meter; this test confirms the call is benign.
|
|
140
|
+
expect(() => recordRequestMetric("GET", "/", 200, 1)).not.toThrow();
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
describe("recordCacheMetric — cache_layer label", () => {
|
|
145
|
+
beforeEach(() => {
|
|
146
|
+
configureMeter({ counterInc: () => {} });
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it("stamps profile + decision + layer when all are provided", () => {
|
|
150
|
+
const { adapter, counters } = captureMeter();
|
|
151
|
+
configureMeter(adapter);
|
|
152
|
+
|
|
153
|
+
recordCacheMetric(true, "product", "HIT", "edge");
|
|
154
|
+
|
|
155
|
+
expect(counters).toHaveLength(1);
|
|
156
|
+
expect(counters[0]?.name).toBe(MetricNames.CACHE_HIT);
|
|
157
|
+
expect(counters[0]?.labels).toMatchObject({
|
|
158
|
+
profile: "product",
|
|
159
|
+
decision: "HIT",
|
|
160
|
+
layer: "edge",
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("emits cache_miss_total when hit=false", () => {
|
|
165
|
+
const { adapter, counters } = captureMeter();
|
|
166
|
+
configureMeter(adapter);
|
|
167
|
+
|
|
168
|
+
recordCacheMetric(false, "search", "MISS", "edge");
|
|
169
|
+
|
|
170
|
+
expect(counters[0]?.name).toBe(MetricNames.CACHE_MISS);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("supports the legacy 3-arg signature for backward compat", () => {
|
|
174
|
+
const { adapter, counters } = captureMeter();
|
|
175
|
+
configureMeter(adapter);
|
|
176
|
+
|
|
177
|
+
recordCacheMetric(true, "static");
|
|
178
|
+
|
|
179
|
+
expect(counters[0]?.labels).toEqual({ profile: "static" });
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("distinguishes cachedLoader vs edge vs vtex-swr layers", () => {
|
|
183
|
+
const { adapter, counters } = captureMeter();
|
|
184
|
+
configureMeter(adapter);
|
|
185
|
+
|
|
186
|
+
recordCacheMetric(true, "loader-x", "HIT", "cachedLoader");
|
|
187
|
+
recordCacheMetric(true, "vtex-product", "HIT", "vtex-swr");
|
|
188
|
+
|
|
189
|
+
expect(counters[0]?.labels?.layer).toBe("cachedLoader");
|
|
190
|
+
expect(counters[1]?.labels?.layer).toBe("vtex-swr");
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
describe("recordCommerceMetric (D-11)", () => {
|
|
195
|
+
beforeEach(() => {
|
|
196
|
+
configureMeter({ counterInc: () => {} });
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("emits commerce_request_duration_ms with provider + operation labels", () => {
|
|
200
|
+
const { adapter, histograms } = captureMeter();
|
|
201
|
+
configureMeter(adapter);
|
|
202
|
+
|
|
203
|
+
recordCommerceMetric(123, {
|
|
204
|
+
provider: "vtex",
|
|
205
|
+
operation: "intelligent-search.product_search",
|
|
206
|
+
status_class: "2xx",
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
expect(histograms).toHaveLength(1);
|
|
210
|
+
expect(histograms[0]?.name).toBe(MetricNames.COMMERCE_REQUEST_DURATION_MS);
|
|
211
|
+
expect(histograms[0]?.value).toBe(123);
|
|
212
|
+
expect(histograms[0]?.labels).toMatchObject({
|
|
213
|
+
provider: "vtex",
|
|
214
|
+
operation: "intelligent-search.product_search",
|
|
215
|
+
status_class: "2xx",
|
|
216
|
+
});
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("includes the cached boolean when provided", () => {
|
|
220
|
+
const { adapter, histograms } = captureMeter();
|
|
221
|
+
configureMeter(adapter);
|
|
222
|
+
|
|
223
|
+
recordCommerceMetric(5, {
|
|
224
|
+
provider: "shopify",
|
|
225
|
+
operation: "graphql.cart_query",
|
|
226
|
+
cached: true,
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
expect(histograms[0]?.labels?.cached).toBe(true);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it("is a no-op when no meter is configured", () => {
|
|
233
|
+
expect(() =>
|
|
234
|
+
recordCommerceMetric(1, { provider: "vtex", operation: "test" }),
|
|
235
|
+
).not.toThrow();
|
|
236
|
+
});
|
|
237
|
+
});
|