Skip to content

feat(fetch): record outgoing fetch duration by external host - #1218

Open
nicacioliveira wants to merge 3 commits into
mainfrom
feat/outgoing-fetch-duration-metric
Open

feat(fetch): record outgoing fetch duration by external host#1218
nicacioliveira wants to merge 3 commits into
mainfrom
feat/outgoing-fetch-duration-metric

Conversation

@nicacioliveira

@nicacioliveira nicacioliveira commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Problem

createFetch already measures the duration of every outgoing fetch. It just throws the number away unless a logger happens to be installed — and logger is null in production:

const start = logger && performance.now();

So there is no metric that answers "is the external API slow, or are we making too many calls to it?". The only alternative is otel_traces, which is tail-sampled at ~1.7% and carries no client spans for these calls at all.

That gap is not academic. Investigating a storefront yesterday, load-data spans of 157s could not be attributed to anything — we could measure VTEX by hand from inside the pod (TTFB 0.39–0.55s, healthy) and measure the page (fast TTFB), but nothing connected the two. Every hypothesis about where the time went was a guess.

Change

An outgoing_fetch_duration histogram, dimensioned by external host and status class.

Three design points, all copied from patterns already in this repo rather than invented:

unit: "ms". The meter provider in observability/otel/metrics.ts selects bucket boundaries by unit — declaring "ms" automatically picks up [10, 100, 500, 1000, 5000, 10000, 15000]. That mechanism is worth calling out because the @decocms/start runtime lacks it and currently records seconds into millisecond buckets on all four of its duration metrics: 99.8%–99.95% of observations land in bucket 1 (measured on production ClickHouse), making every quantile there meaningless. This metric avoids that by construction.

Low cardinality by construction. server.address is the host, never the path, and status is bucketed into a class rather than the raw code — roughly 6 hosts × 5 classes per site (measured: a large VTEX storefront talks to 6 distinct hosts). For contrast, loader_cache reaches 3,684 distinct label values on a single site and 21,849 fleet-wide, because it uses the full resolver chain (Categories@sections.variants.1.value.5.sections.0.section.page) as a label. That is a separate problem, but it is the reason this metric does not take a per-loader dimension.

Failures are recorded, not dropped. A call that hangs and then aborts is the sample you most want, and a success-only path loses exactly those. The error is re-thrown untouched.

hostOf returns null rather than throwing on malformed input, and a null host skips the sample — a metric must not be able to break the fetch path.

Logger behaviour is unchanged.

What this does and does not give you

It attributes time spent inside a single outbound call, per host. It does not attribute time spent between calls — serial fan-out, block resolution, cache writes. If a 157s request turns out to be 200 sequential 700ms calls, this metric shows 200 healthy samples and the aggregate stays unexplained; resolver_latency is the signal for that, and it is currently emitted by only 5 tenants for reasons still unknown.

So this closes one specific gap rather than the whole attribution problem, and I would rather say that plainly than oversell it.

Verification

deno check runtime/fetch/fetchLog.ts — clean.

Not verified: the metric has not been observed end-to-end in ClickHouse, since that needs a release. Worth confirming the series count per tenant after the first deploy — the expectation is single digits.

🤖 Generated with Claude Code


Summary by cubic

Record duration of all outgoing fetches by external host and status class to surface third‑party API latency in production. Adds a low‑cardinality ms histogram and captures failures.

  • New Features

    • Adds outgoing_fetch_duration histogram (unit: ms).
    • Labels: server.address (hostname only) and http.response.status_class (2xx/3xx/4xx/5xx/error).
    • Records successes and failures; errors are rethrown.
    • Skips sampling when the host cannot be derived (includes data:, blob:, file:).
  • Bug Fixes

    • Use URL.hostname for server.address to avoid port-based label splits and match semconv.
    • Fold empty hostname into null to prevent a server.address="" series.

Written for commit 972ebe3. Summary will update on new commits.

Review in cubic

Summary by CodeRabbit

  • Improvements
    • Added outgoing request performance metrics, including duration and HTTP status categories by external host.
    • Transport failures are now captured as error metrics while preserving the original error behavior.
    • Malformed request addresses are handled safely without interrupting fetch operations.
    • Existing outgoing request logging remains available for monitoring and troubleshooting.

This wrapper already measured the duration of every outgoing fetch — it just
discarded it unless a logger happened to be installed, and `logger` is null in
production:

    const start = logger && performance.now();

So there was no metric answering "is the external API slow, or are we making
too many calls to it?". The only alternative was `otel_traces`, which is
tail-sampled at ~1.7% and carries no client spans for these calls at all. In
practice that meant a `load-data` span of 157s could not be attributed to
anything, and diagnosis fell back to guessing.

Adds an `outgoing_fetch_duration` histogram, dimensioned by external host and
status class.

Notes on the design, both copied from what already works in this repo:

- `unit: "ms"`. The meter provider in `observability/otel/metrics.ts` selects
  bucket boundaries by unit, so "ms" picks up
  `[10, 100, 500, 1000, 5000, 10000, 15000]` automatically. Recording seconds
  would put every observation in the first bucket — which is exactly the bug
  the @decocms/start runtime currently has on its four duration metrics
  (99.8%-99.95% in bucket 1, measured).

- Low cardinality by construction: `server.address` is the host, never the path,
  and status is bucketed into a class rather than the raw code. Measured on a
  large VTEX storefront, a site talks to 6 distinct hosts, so this is ~6 x 5
  series per site. For comparison, `loader_cache` reaches 3684 distinct label
  values on a single site because it uses the full resolver chain as a label.

- Failures are recorded, not dropped. A call that hangs for 60s and then aborts
  is the sample you most want and the one a success-only path loses. The error
  is re-thrown untouched.

- `hostOf` returns null instead of throwing on a malformed input, and a null
  host skips the sample. A metric must not be able to break the fetch path.

The logger behaviour is unchanged.

Verified: `deno check runtime/fetch/fetchLog.ts` clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Tagging Options

Should a new tag be published when this PR is merged?

  • 👍 for Patch 1.203.1 update
  • 🎉 for Minor 1.204.0 update
  • 🚀 for Major 2.0.0 update

@coderabbitai

coderabbitai Bot commented Aug 4, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 38397296-0494-43c5-80ee-4cf878aecddd

📥 Commits

Reviewing files that changed from the base of the PR and between d24f22a and 972ebe3.

📒 Files selected for processing (1)
  • runtime/fetch/fetchLog.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • runtime/fetch/fetchLog.ts

📝 Walkthrough

Walkthrough

The change instruments createFetch with OpenTelemetry histogram metrics. It records fetch duration by external host and HTTP status class, records "error" for transport failures, safely extracts request hosts, and rethrows original errors unchanged.

Changes

Fetch Metrics Instrumentation

Layer / File(s) Summary
Duration histogram and status bucketing setup
runtime/fetch/fetchLog.ts
Adds an OpenTelemetry histogram, status-class bucketing, and safe host extraction for malformed inputs.
createFetch timing and error handling
runtime/fetch/fetchLog.ts
createFetch times every request, records successful responses by host and status class, records transport failures as "error", and rethrows the original error.

Estimated code review effort: 2 (Simple) | ~10 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant createFetch
  participant Histogram
  participant ExternalHost

  Caller->>createFetch: invoke fetch(request)
  createFetch->>createFetch: start timing
  createFetch->>ExternalHost: send request
  alt request succeeds
    ExternalHost-->>createFetch: response with status
    createFetch->>Histogram: record duration by host and status class
    createFetch-->>Caller: return response
  else request fails
    ExternalHost-->>createFetch: transport error
    createFetch->>Histogram: record duration as "error"
    createFetch-->>Caller: rethrow original error
  end
Loading
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the primary change: recording outgoing fetch duration by external host.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/outgoing-fetch-duration-metric

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@runtime/fetch/fetchLog.ts`:
- Around line 46-50: Update the hostOf function to return URL.hostname instead
of URL.host for string, URL, and Request inputs, preserving the existing parsing
and null-on-error behavior so port numbers do not affect the external host
metric label.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 98fafbff-04b2-4b53-ba4a-2df869cddc20

📥 Commits

Reviewing files that changed from the base of the PR and between 40762b5 and 96a9a93.

📒 Files selected for processing (1)
  • runtime/fetch/fetchLog.ts

Comment thread runtime/fetch/fetchLog.ts Outdated

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 1 file

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread runtime/fetch/fetchLog.ts Outdated
Comment thread runtime/fetch/fetchLog.ts Outdated
`URL.host` appends a non-default port, so `example.com:8080` and `example.com`
would become two distinct `server.address` label values for the same host —
inflating exactly the cardinality this metric is careful about, and undercutting
the "6 hosts x 5 classes per site" claim in its own docstring.

`hostname` also matches semconv, where `server.address` is the address alone and
`server.port` is a separate attribute.

Caught by CodeRabbit on #1218.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@nicacioliveira

Copy link
Copy Markdown
Contributor Author

Good catch — applied in d24f22a.

URL.host appends a non-default port, so example.com:8080 and example.com would have become two server.address values for the same host. That undercuts the exact property the metric is meant to have, and the docstring right above it claims "6 hosts x 5 classes per site" — so the bug was contradicting its own documentation.

Switched to hostname, which also matches semconv: server.address is the address alone, server.port is a separate attribute. Docstring updated to match. deno check clean.

Authority-less schemes — `data:`, `blob:`, `file:` — parse fine but have no
hostname, so they returned "" and `record` only skipped on null. That would have
created a meaningless `server.address=""` series for calls that never crossed
the network.

Also collapses the three-branch return into a single URL construction.

Verified:
  https://a.com/x         -> a.com
  https://a.com:8080/x    -> a.com
  data:text/plain,hi      -> null
  file:///tmp/x           -> null
  nonsense                -> null

Caught by cubic on #1218.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@nicacioliveira

Copy link
Copy Markdown
Contributor Author

Both cubic findings addressed.

URL.host includes the port (line 48) — same issue CodeRabbit raised; was already fixed in d24f22a before this review landed.

Authority-less URLs produce an empty host (line 50) — valid and not covered by that fix. data:, blob: and file: parse fine but have no hostname, so hostOf returned "" while record only skipped on null. That would have created a meaningless server.address="" series for calls that never crossed the network. Fixed in 972ebe3, which also collapses the three-branch return into a single URL construction.

Verified behaviour:

https://a.com/x         -> a.com
https://a.com:8080/x    -> a.com
data:text/plain,hi      -> null
file:///tmp/x           -> null
nonsense                -> null

deno check clean.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants