Skip to content

feat!: annotate SQL statements with OTel trace context via AtomicOperation - #154

Merged
nicolasburtey merged 2 commits into
mainfrom
feat/sql-trace-context-op-centric
Jul 21, 2026
Merged

feat!: annotate SQL statements with OTel trace context via AtomicOperation#154
nicolasburtey merged 2 commits into
mainfrom
feat/sql-trace-context-op-centric

Conversation

@bodymindarts

@bodymindarts bodymindarts commented Jul 21, 2026

Copy link
Copy Markdown
Member

Summary

Alternative implementation of #153 (sqlcommenter-style trace correlation), per review discussion: instead of wrapping the executor at every macro call site, annotation is centralized in AtomicOperation and the existing OneTimeExecutorno new executor type, zero macro codegen changes.

Every statement executed through AtomicOperation::as_executor() or IntoOneTimeExecutor carries the active sampled span's W3C traceparent as a trailing SQL comment:

INSERT INTO user_events (...) VALUES (...) /*traceparent='00-<32-hex>-<16-hex>-01'*/

Observable in pg_stat_activity and Postgres logs (slow query log, auto_explain, lock waits) for exact per-execution correlation; pg_stat_statements retains the comment of the first execution per query id (comments are excluded from the query-id jumble), yielding an exemplar trace per statement shape.

Design

  • AtomicOperation is split:
    • connection() (required) — raw &mut PgConnection; replaces as_executor() as the method implementors provide
    • as_executor() (provided) — returns OneTimeExecutor<&mut PgConnection>, which annotates
  • OneTimeExecutor now implements sqlx::Executor and hosts the annotation logic. It rewrites statement text only via Execute::sql() + take_arguments(); bind arguments and row mapping flow through unchanged, so sqlx::query! compile-time verification is fully preserved.
  • Macro codegen is byte-identical to main — generated code still calls plain op.as_executor() and gets annotation through the trait. All 85+ token tests pass unmodified.
  • HookOperation inherits the annotating executor, so SQL executed inside commit hooks (pre_commit) is annotated automatically — no per-hook opt-in.
  • Annotation is gated on the sampled flag: an unsampled span is never exported, so its trace id cannot be looked up in the tracing backend; unsampled traffic passes through untouched.

Trade-off: sampled statements bypass the prepared statement cache

The traceparent comment makes each annotated statement's text unique — it can never match sqlx's per-connection prepared statement cache. Annotated statements therefore run with persistent(false) (the unnamed statement): this bypasses the cache instead of thrashing it with single-use named statements, at the cost of a server-side parse + plan per sampled execution. Because annotation is sampled-gated, all unsampled traffic keeps full prepared-statement reuse. The trade-off is inherent to SQL-comment correlation — sampling bounds it; it cannot be eliminated for sampled traffic.

Compared to #153

#153 this PR
macro codegen ~10 files + token tests touched unchanged from main
new public API TraceAnnotatingExecutor, annotate_executor none (trait method split)
commit-hook SQL not annotated annotated automatically
downstream hand-written op.as_executor() SQL not annotated annotated automatically on upgrade
unsampled spans annotated (…-00, unlookupable) pass-through (cache retained)
semver additive (minor) breaking (feat!)

Breaking change

AtomicOperation implementors must implement connection() instead of as_executor(). Org-wide code search found no implementors outside this crate, and every downstream call site observed uses op.as_executor() in executor-argument position (.execute(...) / .fetch_*(...)), which compiles unchanged since OneTimeExecutor implements sqlx::Executor. Raw connection access remains available via connection() and DbOp::tx_mut().

Test plan

  • All 126 es-entity integration tests pass with --all-features against Postgres — every generated read/write path now executes through the new Executor impl, proving transparency
  • tests/trace_annotation.rs proves the traceparent reaches pg_stat_activity, now exercising the real DbOp::as_executor() path used by all generated writes
  • New unit test: valid-but-unsampled span → no annotation, pass-through text
  • 93 macro token tests pass unmodified from main
  • nix flake check green (fmt, clippy --all-features -D warnings, deny, audit)
  • Note: CI's nix run .#nextest runs without --all-features, so the tracing-context-gated integration test is compiled out in CI — this also applies to feat: annotate SQL statements with OTel trace context #153; verified locally instead

🤖 Generated with Claude Code


Note

Medium Risk
Breaking trait change for custom AtomicOperation types, and sampled traffic pays extra parse/plan cost due to non-persistent queries; core persistence paths are affected but annotation is gated on sampling.

Overview
Breaking: AtomicOperation implementors now provide connection() instead of as_executor(); the trait supplies a default as_executor() that wraps the connection in OneTimeExecutor.

Adds sql_commenter to append sqlcommenter-style traceparent comments when tracing-context is enabled and the active span is sampled; unsampled or missing context leaves SQL unchanged.

OneTimeExecutor implements sqlx::Executor and centralizes annotation: sampled statements get rewritten text with persistent(false) (bypassing sqlx’s prepared-statement cache); binds and row mapping are unchanged. Generated macro code still calls op.as_executor() with no codegen edits.

Direct use of connection() does not annotate—callers should use as_executor() for trace correlation (including commit hooks via HookOperation).

Tests cover unit sampling behavior and an integration check that annotated SQL appears in pg_stat_activity.

Reviewed by Cursor Bugbot for commit 4f6be80. Bugbot is set up for automated code reviews on this repo. Configure here.

nicolasburtey and others added 2 commits July 21, 2026 11:42
Add opt-in sqlcommenter-style trace correlation for all statements
executed through es-entity.

New sql_commenter module extracts the W3C traceparent from the
current span (tracing-context feature) and appends it as a SQL
comment. A TraceAnnotatingExecutor rewrites only the statement text
at execution time; bind arguments and row mapping flow through
unchanged, so sqlx::query! compile-time verification is fully
preserved and no macro codegen types change.

The wrapper is applied centrally:

- OneTimeExecutor fetch methods (covers all es_query! reads)
- generated write paths (persist, create, update, delete, nested)

When there is no active span context the original query is passed
through untouched (zero overhead). Annotated statements are marked
non-persistent since the unique comment would otherwise thrash the
per-connection prepared statement cache.

The comment is retained in pg_stat_statements' representative query
text and in Postgres logs, so a statement observed server-side can
be matched to a distributed trace:

  SELECT * FROM pg_stat_statements WHERE query LIKE '%<trace_id>%';

Includes an integration test proving the traceparent of the active
span is visible in pg_stat_activity while a query is in flight.
Alternative to per-call-site executor wrapping: annotation is
centralized in the AtomicOperation trait and the existing
OneTimeExecutor type instead of being applied at every macro codegen
site.

- AtomicOperation is split: implementors now provide connection()
  (raw &mut PgConnection); as_executor() becomes a provided method
  returning a OneTimeExecutor that annotates every statement with the
  current sampled span's traceparent SQL comment.
- OneTimeExecutor implements sqlx::Executor and hosts the annotation
  logic; the TraceAnnotatingExecutor wrapper type is removed.
- Macro codegen is reverted to plain op.as_executor() - generated
  code is unchanged from main.
- HookOperation inherits the annotating executor, so SQL executed in
  commit hooks (pre_commit) is annotated automatically.
- Annotation is gated on the span's sampled flag: unsampled spans are
  never exported, so annotating them would cost without benefit.

Trade-off: an annotated statement's text is unique per span, so it can
never be served from sqlx's per-connection prepared statement cache.
Annotated statements run with persistent(false) (unnamed statement),
bypassing the cache rather than thrashing it, at the cost of a
server-side parse + plan per sampled execution. Unsampled traffic
keeps full prepared-statement reuse.

BREAKING CHANGE: AtomicOperation implementors must implement
connection() instead of as_executor(); as_executor() now returns
OneTimeExecutor<&mut PgConnection> (still an sqlx::Executor, so call
sites passing it to query execution compile unchanged).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@bodymindarts

Copy link
Copy Markdown
Member Author

Alternative implementation of GaloyMoney/obix#79 that keeps the changes out of the macro code.

@nicolasburtey
nicolasburtey marked this pull request as ready for review July 21, 2026 19:51
@nicolasburtey
nicolasburtey merged commit a86780d into main Jul 21, 2026
7 checks passed

@cursor cursor 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.

Cursor Bugbot has reviewed your changes using high effort and found 1 potential issue.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 4f6be80. Configure here.

Comment thread src/one_time_executor.rs
Box::pin(async move {
let q = rebuild_annotated(annotated.as_str(), query)?;
self.executor.fetch_optional(q).await
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Annotation ignores post-hoc span instrument

Medium Severity

annotated_sql / current_traceparent run synchronously when fetch_many or fetch_optional is called, before the returned future or stream is polled. Attaching a span with .instrument(span) after .execute(...) / .fetch_*(...) therefore sees no active context and silently skips the traceparent comment, even though the span is current while the query runs. #[instrument] on an enclosing async function still works because the span is entered before those calls.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 4f6be80. Configure here.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Acknowledged — the observation is technically correct: the annotation decision runs at call time, before the returned future/stream is polled, so .instrument(span) attached directly to a fetch future misses the traceparent comment.

Marking as won't-fix:

  • Every generated repo method uses #[tracing::instrument] attributes, and the supported pattern for manual spans is instrumenting an enclosing async block (see tests/trace_annotation.rs) — in both cases the span is active at call time and annotation works.
  • The failure mode is graceful: a post-hoc-instrumented fetch future just loses the SQL comment (observability gap only); query execution is unaffected.
  • Capture-at-call-time matches common tracing convention (cf. Span::current() captured in constructors), and deferring the check into the future would force a Box::pin onto the unannotated fast path, which currently returns the inner future directly.

If a real downstream use case for post-hoc instrumentation of raw fetch futures shows up, the fix is small (~20 lines: move the annotated_sql() check inside the returned future/stream) and can land as a follow-up.

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