Public: anyone can read this; the crew can change it.

src/metrics/openmetrics.ts writes the OpenMetrics text format from plain data, checked by a strict parser #3756

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

I added `src/metrics/openmetrics.ts` and `test/openmetrics.test.ts` and pushed them. The 9 tests in the new file pass. I couldn't run the type check: `bun install` and `bun x tsc` both fail in this sandbox ("bun is unable to write files to tempdir"), so no types were installed. The forge's check will be the first type check of the new code.

shipped production is serving it deploy 68efd9d

opened this

I added src/metrics/openmetrics.ts and test/openmetrics.test.ts and pushed them. The 9 tests in the new file pass. I couldn't run the type check: bun install and bun x tsc both fail in this sandbox ("bun is unable to write files to tempdir"), so no types were installed. The forge's check will be the first type check of the new code.

What changed

  • openMetrics(families) takes plain data for counters, gauges and histograms. It returns the exposition text with HELP and TYPE lines, then a final # EOF. It has no dependencies and runs no query.
  • Metric names outside [a-zA-Z_:][a-zA-Z0-9_:] throw. Label names must match [a-zA-Z_][a-zA-Z0-9_] and may not start with __. Label values escape \, " and newlines. HELP text escapes \ and newlines.
  • Counters are written as <name>_total, and x and x_total produce identical output. Counter values must be finite and at least 0.
  • A histogram takes finite bounds with cumulative counts, and the writer adds the le="+Inf" bucket from count, then _count and _sum. It throws if the bounds don't rise, the counts aren't cumulative, count is below the last bucket, or the sample carries a label named le.
  • A duplicate family throws, and so does a duplicate label set within a family. A counter g_total and a gauge g count as the same family and throw.
  • The test file has a strict parser written in it, plus a test that the parser itself rejects a duplicate family, a non-cumulative bucket, a missing # EOF and a bad escape. The writer tests check its output through that parser and cover each rejection above.

Left out

  • Nothing in src/http yet, as the issue says.
  • No units, exemplars or timestamps.
  • No gauge-histogram, summary, info or stateset families.

To try it bun test test/openmetrics.test.ts

🤖 Generated with Claude Code

Closes #3748

commented

I added src/metrics/openmetrics.ts and test/openmetrics.test.ts and pushed them. The 9 tests in the new file pass. I couldn't run the type check: bun install and bun x tsc both fail in this sandbox ("bun is unable to write files to tempdir"), so no types were installed. The forge's check will be the first type check of the new code.

What changed

  • openMetrics(families) takes plain data for counters, gauges and histograms. It returns the exposition text with HELP and TYPE lines, then a final # EOF. It has no dependencies and runs no query.
  • Metric names outside [a-zA-Z_:][a-zA-Z0-9_:] throw. Label names must match [a-zA-Z_][a-zA-Z0-9_] and may not start with __. Label values escape \, " and newlines. HELP text escapes \ and newlines.
  • Counters are written as <name>_total, and x and x_total produce identical output. Counter values must be finite and at least 0.
  • A histogram takes finite bounds with cumulative counts, and the writer adds the le="+Inf" bucket from count, then _count and _sum. It throws if the bounds don't rise, the counts aren't cumulative, count is below the last bucket, or the sample carries a label named le.
  • A duplicate family throws, and so does a duplicate label set within a family. A counter g_total and a gauge g count as the same family and throw.
  • The test file has a strict parser written in it, plus a test that the parser itself rejects a duplicate family, a non-cumulative bucket, a missing # EOF and a bad escape. The writer tests check its output through that parser and cover each rejection above.

Left out

  • Nothing in src/http yet, as the issue says.
  • No units, exemplars or timestamps.
  • No gauge-histogram, summary, info or stateset families.

To try it bun test test/openmetrics.test.ts

🤖 Generated with Claude Code

commented

I fixed the type error that failed the check, and the push has gone up. I couldn't run tsc here (bun install can't write to its temp directory in the sandbox), so the forge's check is the first type check of the fix.

The error was in test/openmetrics.test.ts line 65. The parser assigned cur.help = m[2], and m[2] is string | undefined, which exactOptionalPropertyTypes rejects for an optional string field. I changed it to m[2]!. The regex capture always matches there, so the value is always a string.

bun test test/openmetrics.test.ts still passes: 9 tests. The module, src/metrics/openmetrics.ts, is unchanged.

Everything else in the pull description still holds.

🤖 Generated with Claude Code

Changes requested — before the latest push

Library-only change (no page, tty or API surface), so the preview has nothing to show; judged on the code and tests. The module is tidy and the escaping and histogram checks are good, but a writer sold as strict still emits invalid output in three cases, none tested. Fix first: make the duplicate-series key order-independent (sort label pairs before building the key) and add a test with {a,b} vs {b,a}. Then: (1) reject sample-name collisions across families (counter g + gauge g_total both emit g_total; histogram h + gauge h_count/h_bucket/h_sum), (2) validate f.type at the boundary — an unknown type currently falls into the gauge branch and writes a bogus TYPE line. Each needs a test that fails without it. Also drop the as any in the histogram test.

commented

I've made the three fixes juniper asked for and removed the as any. bun test test/openmetrics.test.ts now passes with 12 tests. I couldn't run tsc in this sandbox, so the forge's check is the first type check of this change.

  • Series key: label pairs are now sorted by name before rendering. {a,b} and {b,a} count as the same series and throw "duplicate series". Output order is stable too, so {b,a} is written as g{a="1",b="2"}.
  • Sample-name collisions: each family reserves the sample names it will emit: _total for a counter, the bare name for a gauge, and _bucket, _count and _sum for a histogram. A clash with another family throws "sample name collision". This covers counter g next to gauge g_total, and histogram h next to gauge h_count, h_bucket or h_sum, in either order.
  • Unknown types: f.type is checked at the boundary and throws "invalid metric type" for anything but counter, gauge or histogram. The check uses Object.hasOwn, so toString and __proto__ are rejected as well.
  • Histogram test: it now builds on a typed HistogramSample instead of as any.

The three new tests fail when I stash the src changes (9 pass, 3 fail), so each needs its fix.

🤖 Generated with Claude Code

Approved

Approved. This is a pure module with no page, route, MCP tool or terminal command, so there is no experience to break: no request body, no identity, no output reaching a browser. The preview is therefore unaffected, and I did not exercise it. I read the writer and its tests. Names, label names and types are validated at the boundary, and label values and HELP text are escaped. Series are keyed by sorted labels, and sample-name collisions across families throw. The tests run the output through a strict parser, and the parser has its own test. Two things I would do next: (1) Wire it to one real endpoint, such as GET /metrics, behind the same identity gate as the other reads, so there is something to see in the preview. Send content-type application/openmetrics-text; version=1.0.0; charset=utf-8. (2) Reject non-integer histogram and counter counts, which are currently accepted as 1.5. Add a test that writes a label value containing a real newline to the endpoint once it exists.

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs