ateapi: trace PostgreSQL statements in the store - #1462
ateapi: trace PostgreSQL statements in the store#1462Da Huang (git286) wants to merge 4 commits into
Conversation
The store's pgx pools carried no tracer, so no span was ever emitted for database work: a traced GetActor arrived as a single gRPC server span with the PostgreSQL time indistinguishable from handler overhead, and even the multi-span suspend/resume traces showed step, atelet and snapshot spans but nothing for the many store round-trips between them. The store blind spot in docs/metrics/substrate.yaml describes the metrics half of this; the trace half is what this change closes. Attach a QueryTracer to the pool config (the watch pool inherits it via Copy). Each statement becomes a client span named after its leading keyword (db.SELECT, db.UPDATE, ...) with the parameterized statement text as db.query.text — arguments never appear in it. Spans only join an existing trace: statements from background work (outbox polling, lease maintenance) carry no surrounding span, and opening a root span for each would flood the backend with single-span traces. pgx.ErrNoRows leaves the span unmarked, since the store maps it to NotFound as an expected lookup outcome.
| if !trace.SpanContextFromContext(ctx).IsValid() { | ||
| return ctx | ||
| } | ||
| ctx, _ = otel.Tracer("atepg").Start(ctx, querySpanName(data.SQL), |
There was a problem hiding this comment.
Is an error here an impossible condition? Are we ok swallowing the error?
There was a problem hiding this comment.
The discarded value is the span, not an error. Start cannot fail. Its signature is Start(ctx, name, ...) (context.Context, Span). The OpenTelemetry API is built so tracing never breaks the app: if sampling is off you just get an inert span back, never an error.
We discard the span because Start also stores a copy inside the returned context, and that copy is the one we need. The span gets closed in a different function: pgx carries our returned context through the query and passes it to TraceQueryEnd, which retrieves the span with trace.SpanFromContext and ends it. A local span variable would go out of scope as soon as TraceQueryStart returns.
Krisztian F (krisztianfekete)
left a comment
There was a problem hiding this comment.
Thanks, added some comments around mainly semconv and performance!
| if data.Err != nil && !errors.Is(data.Err, pgx.ErrNoRows) { | ||
| span.RecordError(data.Err) | ||
| span.SetStatus(codes.Error, data.Err.Error()) | ||
| } |
There was a problem hiding this comment.
Is it possible that this is dead code?
There was a problem hiding this comment.
You are right, it is dead code. pgx never passes ErrNoRows to TraceQueryEnd. It creates that error for the caller after the rows are already closed, so the tracer only sees the real error from the result reader. I removed the check and the test for it. I also added a test against a real PostgreSQL that does a lookup miss and checks the span stays unmarked.
|
|
||
| // ErrNoRows is an expected lookup outcome and must not mark the span. | ||
| qctx = qt.TraceQueryStart(ctx, nil, pgx.TraceQueryStartData{SQL: "SELECT id FROM actors"}) | ||
| qt.TraceQueryEnd(qctx, nil, pgx.TraceQueryEndData{Err: pgx.ErrNoRows}) |
There was a problem hiding this comment.
There was a problem hiding this comment.
Removed together with the ErrNoRows branch.
| sr := tracetest.NewSpanRecorder() | ||
| tp := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(sr)) | ||
| prev := otel.GetTracerProvider() | ||
| otel.SetTracerProvider(tp) |
There was a problem hiding this comment.
We have a convention of not swapping the global provider in our tests.
There was a problem hiding this comment.
Fixed. The tracer now takes a TracerProvider in its constructor. Connect passes the global one and the tests pass a local recording one, same as the controlapi tests. All tracer tests run with t.Parallel now.
| // Per-statement trace spans; the watch pool inherits this through Copy(). | ||
| cfg.ConnConfig.Tracer = queryTracer{} |
There was a problem hiding this comment.
Can we add a simple test to assert what the comment states?
There was a problem hiding this comment.
Added TestPoolConfigTracerSharedWithWatchPool. It calls poolConfig, copies the config and checks both point to the same tracer.
| if len(fields) == 0 { | ||
| return "db.query" | ||
| } | ||
| return "db." + strings.ToUpper(fields[0]) |
There was a problem hiding this comment.
Can we have db.query.summary, i.e. SELECT actors to match semconv's preferences?
See https://opentelemetry.io/docs/specs/semconv/db/database-spans/
There was a problem hiding this comment.
Done. Span name is now the summary like "SELECT actors" or "UPDATE workers". The store uses one hand written statement per call so a small keyword scan is enough. It takes the first keyword and the table after FROM, INTO, UPDATE or TABLE. If the statement has a JOIN, a subquery or no table, only the keyword is used, as the spec asks. I kept the original casing so BEGIN and COMMIT from pgx show as "begin" and "commit". The test runs the scan over the real statements from the store.
| // NotFound), not a query failure. | ||
| if data.Err != nil && !errors.Is(data.Err, pgx.ErrNoRows) { | ||
| span.RecordError(data.Err) | ||
| span.SetStatus(codes.Error, data.Err.Error()) |
There was a problem hiding this comment.
I think we should have access to SQLSTATE here, if so, can we pull it into db.response.status_code + error.type?
There was a problem hiding this comment.
Yes, we have it. For a PgError I now set db.response.status_code and error.type to the SQLSTATE. For other errors like a canceled context, error.type is the Go type name. The PostgreSQL test checks a missing table gives 42P01.
| ctx, _ = otel.Tracer("atepg").Start(ctx, querySpanName(data.SQL), | ||
| trace.WithSpanKind(trace.SpanKindClient), | ||
| trace.WithAttributes( | ||
| attribute.String("db.system.name", "postgresql"), |
There was a problem hiding this comment.
Can we use semconv.DBSystemNamePostgreSQL / semconv.DBQueryTextKey instead? Also missing while we're in here:
server.address(Required),db.namespace,db.operation.name,db.collection.name
There was a problem hiding this comment.
Switched to the semconv v1.40.0 constants. Added server.address, server.port, db.namespace, db.operation.name, db.collection.name and db.query.summary. The connection level ones are computed once from the pool config, not per statement, because conn.Config() copies the whole config on every call. Small note, the spec lists server.address as Recommended, not Required, but it is cheap so I added it.
| if !trace.SpanContextFromContext(ctx).IsValid() { | ||
| return ctx | ||
| } |
There was a problem hiding this comment.
Can we use IsSampled() here? Should be identical but cheaper, right?
There was a problem hiding this comment.
Good idea. Changed to IsSampled. It is not exactly the same check but it is safe here because every sampler in serverboot is ParentBased, so a child of an unsampled parent can never be sampled. It saves the non recording span and the context wrap on every statement. I left a comment in the code about the ParentBased assumption. I also made TraceQueryEnd only end a span that TraceQueryStart opened, so an unsampled parent is never touched.
| if !trace.SpanContextFromContext(ctx).IsValid() { | ||
| return ctx | ||
| } | ||
| ctx, _ = otel.Tracer("atepg").Start(ctx, querySpanName(data.SQL), |
There was a problem hiding this comment.
Can we cache the tracer in a struct field instead of resolving it per statement?
There was a problem hiding this comment.
Done. The tracer is resolved once in the constructor and kept in a struct field. This also gives the tests a place to inject their provider.
| func querySpanName(sql string) string { | ||
| fields := strings.Fields(sql) | ||
| if len(fields) == 0 { | ||
| return "db.query" | ||
| } | ||
| return "db." + strings.ToUpper(fields[0]) | ||
| } |
There was a problem hiding this comment.
What do you think about skipping tx control statements, or wrap the tx in one INTERNAL span and nest the statements? Otherwise this might get a bit noisy as we are getting regular spans for BEGIN/INSERT/COMMIT/etc.
There was a problem hiding this comment.
I would keep them. In pgx, Begin and Commit are normal Exec calls, so each one is a real round trip to the server. COMMIT is where the WAL flush waits, so a lot of the PostgreSQL time we want to see in the trace lands in that span. If we skip it, the gap shows up as unexplained time in the parent span again.
The noise is also smaller than it looks. Only three transactions in atepg.go run on the request path. The outbox ones run in background work with no parent span, so they never produce spans.
I like the idea of one INTERNAL span per transaction with the statements nested under it. That needs a small helper around pool.Begin in the store, so I would do it in a follow up PR to keep this one focused on the pgx tracer. Is that ok with you?
There was a problem hiding this comment.
Sure, would you mind opening an issue to track this?
Review follow-ups for the pgx QueryTracer.
Spans are named by their query summary ("SELECT actors", "commit") and
carry db.operation.name, db.collection.name, db.query.summary,
server.address, server.port and db.namespace from the semconv v1.40.0
package, with the connection-level attributes computed once from the
pool configuration. A failed statement records its SQLSTATE as
db.response.status_code and error.type; other errors report their Go
type. The tracer is resolved once, in a constructor that takes the
TracerProvider, so tests inject a local recording provider instead of
swapping the global one.
Statements under an unsampled parent are skipped with IsSampled, which
is safe because every serverboot sampler is ParentBased, and
TraceQueryEnd only ends a span that TraceQueryStart opened. The
pgx.ErrNoRows check is gone: pgx synthesizes that error for the caller
after the rows are closed, so it never reached the tracer.
A test against the PostgreSQL testcontainer checks the span sequence
that real pgx calls produce, including begin and commit, an unmarked
lookup miss, and SQLSTATE 42P01 for a missing table.
716f392 to
46dc944
Compare
staticcheck SA1019: Value.Emit is deprecated in favor of Value.String.
Krisztian F (krisztianfekete)
left a comment
There was a problem hiding this comment.
Can you please add a few lines to observability docs and maybe docs/dev/best-practices/tracing.md? E.g. saying that the store now emits one span per statement, that it follows the component sampler and has no separate on/off switch, and that begin and commit appear as spans.
| at := -1 | ||
| for i, f := range fields[1:] { | ||
| switch { | ||
| case strings.EqualFold(f, "JOIN"): | ||
| return operation, "" | ||
| case at < 0 && strings.EqualFold(f, marker): | ||
| at = i + 2 | ||
| } | ||
| } |
There was a problem hiding this comment.
This takes the first FROM, which is the one inside the subquery when the outer SELECT has no table.
If there is a nested SELECT after the first word, can we return no collection? Then both become keyword only.
There was a problem hiding this comment.
Good catch, fixed. Now a SELECT that comes before the FROM means the FROM belongs to a subquery, so we return only the keyword. SELECT EXISTS(SELECT 1 FROM t) and SELECT (SELECT xid FROM t) become just SELECT. I check only before the FROM, so SELECT proto FROM actors WHERE name IN (SELECT ...) still gives actors and INSERT INTO t SELECT ... still gives t. Added these cases to the test.
A SELECT that opens before the first FROM owns that FROM, as in SELECT EXISTS(SELECT 1 FROM t), so the outer statement reads no table and the summary is the keyword alone. A subquery after the FROM, or the SELECT feeding an INSERT INTO, leaves the collection in place. The observability guide and the tracing best practices now describe the store spans: one client span per statement named by its query summary, begin and commit included, joining only already sampled traces with no switch of their own, and none from background work.
|
Added the docs. One paragraph in the Tracing section of docs/observability.md and one in the sampling section of docs/dev/best-practices/tracing.md. They say the store emits one client span per statement named by the query summary, that begin and commit show up as spans, that the spans only join a trace that is already sampled so they follow the ateapi sampler and have no switch of their own, and that background work like outbox polling produces no spans. The subquery case is fixed and the follow up for transaction spans is #1537. The last run-tests job failed on TestActorLifecycle_WithExternalVolumes, which is the flaky test in #1533. This push runs it again. |
The store's pgx pools carried no tracer, so no span was ever emitted for database work: a traced GetActor arrived as a single gRPC server span with the PostgreSQL time indistinguishable from handler overhead, and even the multi-span suspend/resume traces showed step, atelet and snapshot spans but nothing for the many store round-trips between them. The store blind spot in docs/metrics/substrate.yaml describes the metrics half of this; the trace half is what this change closes.
Attach a QueryTracer to the pool config (the watch pool inherits it via Copy). Each statement becomes a client span named after its leading keyword (db.SELECT, db.UPDATE, ...) with the parameterized statement text as db.query.text — arguments never appear in it. Spans only join an existing trace: statements from background work (outbox polling, lease maintenance) carry no surrounding span, and opening a root span for each would flood the backend with single-span traces. pgx.ErrNoRows leaves the span unmarked, since the store maps it to NotFound as an expected lookup outcome.
Fixes #1455