Skip to content

Add opt-in db.operation.name attribute on db.client.operation.duration/errors - #87

Open
fatmcgav wants to merge 3 commits into
exaring:mainfrom
fatmcgav:metric-db-operation-name
Open

fatmcgav wants to merge 3 commits into
exaring:mainfrom
fatmcgav:metric-db-operation-name

Conversation

@fatmcgav

@fatmcgav fatmcgav commented Sep 10, 2026 •

Copy link
Copy Markdown

Closes #86.

Summary

WithMetricOperationName(fn OperationNameFunc) — a new, opt-in option — attaches db.operation.name to db.client.operation.duration/db.client.operation.errors for query, prepare, and per-statement batch-query calls, per the OTel database metrics semconv. Default (unset): unchanged behaviour, no new attribute.

Design decisions

  • Default-off, independent option, per review: decoupled entirely from spanNameCtxFunc/WithSpanNameFunc/WithSpanNameCtxFunc. The span path is untouched. fn only runs once WithMetricOperationName is set, and then runs on every call (sampled or not), since metric recording is decoupled from trace sampling.
  • Exported the existing default SQL-verb parser as SQLOperationName (was defaultSpanNameCtxFunc) so it can be passed explicitly: WithMetricOperationName(otelpgx.SQLOperationName). Rebased onto the Default behvior of sqlOperationName does not ignore comments #63 fix, so it skips leading SQL comments — sqlc-style -- name: ... queries produce the real verb rather than a -- label on the metric.
  • No cache: benchmarked first — cached attribute.Set lookups were ~16.6ns/0 allocs vs. ~162.6ns/3 allocs uncached. That ~146ns difference is immaterial next to a real Postgres round trip (tens–hundreds of µs even on loopback), so I dropped the cache entirely rather than capping it.
  • batch: TraceBatchQuery's per-statement error increment gets its own operation name; the whole-batch aggregate in TraceBatchEnd stays unlabelled (no single name for a possibly-mixed batch).
  • connect/acquire/copy unaffected.

Testing

TestTracer_metricOperationName covers: unset (no attribute), SELECT/INSERT/DELETE/UPDATE, prepare, per-statement batch error, batch aggregate (no name), and a custom OperationNameFunc. go build, go vet, gofmt -l, go test -race ./... all pass locally.

@fatmcgav

Copy link
Copy Markdown
Author

@obitech Thoughts on this one welcome 👍

@obitech obitech left a comment •

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I'm not a big fan of this in its current state because the claim "cardinality remains small" is only true when using the default parser. Complex span name parsing logic would now lead to:

  • The func being applied on every call instead of just when spans are recording, incurring that cost for everyone.
  • Potentially unbounded label cardinality
  • A potentially unbounded sync.Map cache

I'd prefer this to be default-off and then have a dedicated option that decouples the metric attribute from the span name hook entirely. Something like:

// OperationNameFunc returns the value for db.operation.name on the
// operation duration and error metrics. Return "" to omit the attribute.
type OperationNameFunc func(ctx context.Context, sql string) string

// WithMetricOperationName enables db.operation.name on
// db.client.operation.duration and db.client.operation.errors.
func WithMetricOperationName(fn OperationNameFunc) Option

We should also export the default parser so users can write WithMetricOperationName(otelpgx.SQLOperationName) instead of some special nil argument.

The upsides here are that the span path stays as it is today, and nobody gets a new label or a hook running on unsampled calls just by bumping the lib. Also the separate function forces a conscious decision to increase cardinality.

For the cache: please benchmark whether it's even measurable next to the Postgres round trip. If not, you can drop it. If it is, cap the number of entries and build the attribute set uncached past the cap. I don't think we need any special eviction logic here.

The batch handling and the new test are good and should carry over.

Last note: the PR description is much longer than the diff warrants. A short summary and the design decisions are enough.

Comment thread README.md Outdated
Comment thread tracer.go Outdated
Comment thread tracer.go Outdated
@fatmcgav

Copy link
Copy Markdown
Author

Thanks for the review @obitech... Will re-work...

Also, just noticed that there is still a "legacy" SQLOperationName func for go < 1.24... Now that those versions are long since deprecated, any objections to cleaning up?

fatmcgav added a commit to fatmcgav/otelpgx that referenced this pull request Sep 10, 2026
Per @obitech's review: the previous default-on design assumed
`db.operation.name` cardinality "stays small" — true for the default
parser, but not for a caller-supplied `WithSpanNameCtxFunc`/
`WithSpanNameFunc`, which could return arbitrary, high-cardinality
values. It also ran `spanNameCtxFunc` on every call regardless of trace
sampling, and cached `attribute.Set`s in an unbounded `sync.Map`.

Benchmarked the cache first, as requested: cached lookups were
~16.6ns/0 allocs vs. ~162.6ns/3 allocs (448B) uncached — a ~146ns
difference that's immaterial next to any real Postgres round trip
(tens to hundreds of µs even on loopback). Dropped it entirely rather
than capping it, per the reviewer's own fallback.

Replaces the default-on behaviour with a new, independent option:

    type OperationNameFunc func(ctx context.Context, sql string) string
    func WithMetricOperationName(fn OperationNameFunc) Option

- Default is unset: `db.client.operation.duration`/`db.client.operation.errors`
  carry no `db.operation.name` unless this is explicitly configured, so
  nobody gets a new label or an SQL-parsing hook running on unsampled
  calls just by upgrading.
- Fully decoupled from `spanNameCtxFunc`/`WithSpanNameFunc`/
  `WithSpanNameCtxFunc` — the span-naming path is untouched and back to
  exactly its pre-exaring#87 behaviour (operation name computed only when the
  span is recording).
- Exported the existing first-word SQL parser as `SQLOperationName`
  (was the unexported `defaultSpanNameCtxFunc`) so it can be passed
  explicitly: `WithMetricOperationName(otelpgx.SQLOperationName)`.
  It's still the default for `WithSpanNameFunc`/`WithSpanNameCtxFunc`.
- `attributeSetFor` builds the attribute.Set fresh per call rather than
  caching it, per the benchmark above.

Batch handling, the new test, and the general approach otherwise carry
over from exaring#87 as-is, per review. Dropped the "Metrics" README section
(reviewer: "I don't see value in this block") — the option's doc
comment covers it, matching how every other `Option` here is
documented.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@fatmcgav fatmcgav changed the title Attach db.operation.name to db.client.operation.duration/errors Add opt-in db.operation.name attribute on db.client.operation.duration/errors Sep 10, 2026
@fatmcgav

Copy link
Copy Markdown
Author

Thanks for the detailed review — pushed a redesign addressing all of it:

  • Opt-in, decoupled from span naming: replaced default-on with a new WithMetricOperationName(fn OperationNameFunc) option, exactly as you sketched. db.client.operation.duration/db.client.operation.errors carry no db.operation.name unless this is explicitly set, and fn is entirely independent of spanNameCtxFunc/WithSpanNameFunc/WithSpanNameCtxFunc — the span path is untouched.
  • Exported the default parser as SQLOperationName (was defaultSpanNameCtxFunc), so WithMetricOperationName(otelpgx.SQLOperationName) gets the same low-cardinality behaviour as the span path's default, or a custom fn for anything else.
  • Benchmarked the cache before touching it: cached attribute.Set lookups were ~16.6ns/0 allocs vs. ~162.6ns/3 allocs (448B) building fresh. That ~146ns difference is immaterial next to a real Postgres round trip (tens–hundreds of µs even on loopback), so I dropped the cache entirely rather than capping it.
  • Batch handling and the test carried over as you suggested (extended with an "unset by default" case and a custom-fn case).
  • Trimmed the PR description and dropped the README section.

Ready for another look whenever convenient.

@fatmcgav

Copy link
Copy Markdown
Author

Also, just noticed that there is still a "legacy" SQLOperationName func for go < 1.24... Now that those versions are long since deprecated, any objections to cleaning up?

Split this into #88 👍

@fatmcgav
fatmcgav requested a review from obitech September 10, 2026 15:00
@fatmcgav

Copy link
Copy Markdown
Author

@obitech LMK if you think this needs any further tweaks 👍

@fatmcgav

Copy link
Copy Markdown
Author

@obitech Hope you had a good weekend :) Could I get another review on this one? 🙇‍♂️

@obitech

obitech commented Oct 6, 2026

Copy link
Copy Markdown
Member

@fatmcgav could you rebase please?

fatmcgav and others added 3 commits October 6, 2026 07:17
`recordOperationDuration`/`incrementOperationErrorCount` only ever
looked up a fixed `attribute.Set` from `t.metricAttrs[pgxOperation]`,
precomputed once per pgx call kind in `createAttributeSets()` at
`NewTracer()` time — so `db.client.operation.duration` could only ever
be broken down by call kind (query/batch/copy/prepare/connect/acquire),
never by SQL operation (`SELECT`/`INSERT`/…), even though
`spanNameCtxFunc` already computes that value for the equivalent span
attribute a few lines away.

Per the OTel database metrics semconv
(https://opentelemetry.io/docs/specs/semconv/database/database-metrics/),
`db.operation.name` is Conditionally Required "if readily available and
if there is a single operation name that describes the database call".

This threads the already-computed operation name into the metric path
for `query`, `prepare`, and per-statement `batch` calls:

- `attributeSetFor` builds (and lazily caches, keyed by
  `(kind, operation name)`) an `attribute.Set` that also carries
  `db.operation.name`, falling back to the existing static per-kind set
  when no name applies. SQL verbs are a small, bounded vocabulary, so
  the cache stays small for the tracer's lifetime.
- For `query`/`prepare`, the name is computed in `*Start` but recorded
  in `*End`, so it's threaded through `context.Context` via a new
  `operationNameCtxKey`, mirroring the existing `startTimeCtxKey`
  pattern. It's now computed unconditionally (before the
  `IsRecording()` check), since metric recording is already decoupled
  from trace sampling.
- For `batch`, `TraceBatchQuery` attaches each statement's own name to
  its per-query error increment. The whole-batch aggregate recorded in
  `TraceBatchEnd` stays unlabelled: a batch can mix operation types, so
  there's no single name that describes it as a whole.
- `connect`/`acquire`/`copy` are unaffected — no SQL statement to name.

This ships default-on rather than behind an `Option`: `db.operation.name`
is a stable semconv attribute with low, bounded cardinality, so it seemed
like a strict improvement rather than something to gate.

Adds `TestTracer_metricOperationName` in `tracer_test.go` (covering
SELECT/INSERT/UPDATE/DELETE, prepare, per-statement batch errors, and
the unlabelled batch aggregate, all against a noop `TracerProvider` to
demonstrate the metric still gets the attribute when no span is
recording), extends `meter_test.go`'s `dataPointAttributes` test helper
with a `Histogram[float64]` case, and documents the new attribute in
`README.md`.

Addresses exaring#86.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Per @obitech's review: the previous default-on design assumed
`db.operation.name` cardinality "stays small" — true for the default
parser, but not for a caller-supplied `WithSpanNameCtxFunc`/
`WithSpanNameFunc`, which could return arbitrary, high-cardinality
values. It also ran `spanNameCtxFunc` on every call regardless of trace
sampling, and cached `attribute.Set`s in an unbounded `sync.Map`.

Benchmarked the cache first, as requested: cached lookups were
~16.6ns/0 allocs vs. ~162.6ns/3 allocs (448B) uncached — a ~146ns
difference that's immaterial next to any real Postgres round trip
(tens to hundreds of µs even on loopback). Dropped it entirely rather
than capping it, per the reviewer's own fallback.

Replaces the default-on behaviour with a new, independent option:

    type OperationNameFunc func(ctx context.Context, sql string) string
    func WithMetricOperationName(fn OperationNameFunc) Option

- Default is unset: `db.client.operation.duration`/`db.client.operation.errors`
  carry no `db.operation.name` unless this is explicitly configured, so
  nobody gets a new label or an SQL-parsing hook running on unsampled
  calls just by upgrading.
- Fully decoupled from `spanNameCtxFunc`/`WithSpanNameFunc`/
  `WithSpanNameCtxFunc` — the span-naming path is untouched and back to
  exactly its pre-exaring#87 behaviour (operation name computed only when the
  span is recording).
- Exported the existing first-word SQL parser as `SQLOperationName`
  (was the unexported `defaultSpanNameCtxFunc`) so it can be passed
  explicitly: `WithMetricOperationName(otelpgx.SQLOperationName)`.
  It's still the default for `WithSpanNameFunc`/`WithSpanNameCtxFunc`.
- `attributeSetFor` builds the attribute.Set fresh per call rather than
  caching it, per the benchmark above.

Batch handling, the new test, and the general approach otherwise carry
over from exaring#87 as-is, per review. Dropped the "Metrics" README section
(reviewer: "I don't see value in this block") — the option's doc
comment covers it, matching how every other `Option` here is
documented.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Dropped the doc comment on `recordOperationDuration` repeating
  `incrementOperationErrorCount`'s `operationName` explanation verbatim;
  points to it instead.
- Tightened `attributeSetFor`'s doc: the full semconv quote is already
  in `WithMetricOperationName`'s doc, so just keep the cache-rationale
  part here.
- Removed the doc comment on `TestTracer_metricOperationName` — no
  other test function in this file has one, and the sub-test names
  already say what's covered.
- Shortened the two near-identical "only computed when ... runs
  regardless of sampling" comments in `TraceQueryStart`/`TracePrepareStart`,
  and the `TraceBatchQuery`/`metricOperationNameCtxKey` comments, to their
  essential point.
- Trimmed `WithMetricOperationName`'s doc comment, cutting a sentence
  that just restated the cardinality point already made.

No behavioural change.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@fatmcgav
fatmcgav force-pushed the metric-db-operation-name branch from 9341e83 to 923af0d Compare October 6, 2026 06:22
@fatmcgav

fatmcgav commented Oct 6, 2026

Copy link
Copy Markdown
Author

@obitech Done 👍

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.

db.client.operation.duration/errors never get db.operation.name, unlike the equivalent span attribute

2 participants