-
Notifications
You must be signed in to change notification settings - Fork 308
ateapi: trace PostgreSQL statements in the store #1462
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
50472cf
46dc944
1fffb91
2de0988
2ba7832
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,180 @@ | ||
| // Copyright 2026 Google LLC | ||
| // | ||
| // Licensed under the Apache License, Version 2.0 (the "License"); | ||
| // you may not use this file except in compliance with the License. | ||
| // You may obtain a copy of the License at | ||
| // | ||
| // http://www.apache.org/licenses/LICENSE-2.0 | ||
| // | ||
| // Unless required by applicable law or agreed to in writing, software | ||
| // distributed under the License is distributed on an "AS IS" BASIS, | ||
| // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. | ||
| // See the License for the specific language governing permissions and | ||
| // limitations under the License. | ||
|
|
||
| package atepg | ||
|
|
||
| import ( | ||
| "context" | ||
| "fmt" | ||
| "strings" | ||
|
|
||
| "github.com/jackc/pgx/v5" | ||
| "go.opentelemetry.io/otel/attribute" | ||
| "go.opentelemetry.io/otel/codes" | ||
| semconv "go.opentelemetry.io/otel/semconv/v1.40.0" | ||
| "go.opentelemetry.io/otel/trace" | ||
| ) | ||
|
|
||
| // queryTracer is a pgx QueryTracer that opens one client span per statement, | ||
| // so an RPC trace shows where its time went inside PostgreSQL. Statements are | ||
| // parameterized ($1, $2, ...), so db.query.text carries no argument values. | ||
| // | ||
| // Spans follow the OpenTelemetry database semantic conventions: the span name | ||
| // is the query summary ("SELECT actors", "UPDATE workers", "commit"), and the | ||
| // connection-level attributes are computed once from the pool configuration. | ||
| type queryTracer struct { | ||
| tracer trace.Tracer | ||
| // attrs holds db.system.name, server.address, server.port and | ||
| // db.namespace, which are the same for every statement on the pool. | ||
| attrs []attribute.KeyValue | ||
| } | ||
|
|
||
| var _ pgx.QueryTracer = (*queryTracer)(nil) | ||
|
|
||
| // newQueryTracer builds the tracer for pools opened from cc. The tracer is | ||
| // resolved once here rather than per statement. | ||
| func newQueryTracer(tp trace.TracerProvider, cc *pgx.ConnConfig) *queryTracer { | ||
| return &queryTracer{ | ||
| tracer: tp.Tracer("atepg"), | ||
| attrs: []attribute.KeyValue{ | ||
| semconv.DBSystemNamePostgreSQL, | ||
| semconv.ServerAddress(cc.Host), | ||
| semconv.ServerPort(int(cc.Port)), | ||
| semconv.DBNamespace(cc.Database), | ||
| }, | ||
| } | ||
| } | ||
|
|
||
| // querySpanKey marks a context whose statement span was opened by | ||
| // TraceQueryStart, so TraceQueryEnd never ends a span it did not start. | ||
| type querySpanKey struct{} | ||
|
|
||
| func (t *queryTracer) TraceQueryStart(ctx context.Context, _ *pgx.Conn, data pgx.TraceQueryStartData) context.Context { | ||
| // Join sampled traces only. Statements issued from background work | ||
| // (outbox polling, lease maintenance) carry no span, and opening a root | ||
| // span for each would flood the backend with single-span traces. An | ||
| // unsampled parent is skipped too: every sampler serverboot installs is | ||
| // ParentBased, so its children could never be sampled, and skipping | ||
| // them saves the non-recording span and context wrap per statement. | ||
| if !trace.SpanContextFromContext(ctx).IsSampled() { | ||
| return ctx | ||
| } | ||
| operation, collection := querySummary(data.SQL) | ||
| attrs := make([]attribute.KeyValue, 0, len(t.attrs)+4) | ||
| attrs = append(attrs, t.attrs...) | ||
| attrs = append(attrs, semconv.DBQueryText(data.SQL)) | ||
| name := "postgresql" | ||
| if operation != "" { | ||
| name = operation | ||
| attrs = append(attrs, semconv.DBOperationName(operation)) | ||
| if collection != "" { | ||
| name += " " + collection | ||
| attrs = append(attrs, semconv.DBCollectionName(collection)) | ||
| } | ||
| attrs = append(attrs, semconv.DBQuerySummary(name)) | ||
| } | ||
| ctx, span := t.tracer.Start(ctx, name, | ||
| trace.WithSpanKind(trace.SpanKindClient), | ||
| trace.WithAttributes(attrs...)) | ||
| return context.WithValue(ctx, querySpanKey{}, span) | ||
| } | ||
|
|
||
| func (*queryTracer) TraceQueryEnd(ctx context.Context, _ *pgx.Conn, data pgx.TraceQueryEndData) { | ||
| span, ok := ctx.Value(querySpanKey{}).(trace.Span) | ||
| if !ok { | ||
| return | ||
| } | ||
| // pgx.ErrNoRows never arrives here: pgx synthesizes it for the caller | ||
| // after the rows are closed, so a lookup miss ends the span cleanly. | ||
| if data.Err != nil { | ||
| span.RecordError(data.Err) | ||
| span.SetStatus(codes.Error, data.Err.Error()) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think we should have access to
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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. |
||
| if code := pgErrCode(data.Err); code != "" { | ||
| // SQLSTATE is both the server's status code and the most | ||
| // useful low-cardinality error class. | ||
| span.SetAttributes(semconv.DBResponseStatusCode(code), semconv.ErrorTypeKey.String(code)) | ||
| } else { | ||
| span.SetAttributes(semconv.ErrorTypeKey.String(fmt.Sprintf("%T", data.Err))) | ||
| } | ||
| } | ||
| span.End() | ||
| } | ||
|
|
||
| // querySummary extracts the db.operation.name and db.collection.name of a | ||
| // statement: the leading keyword, and the single table it acts on. Both are | ||
| // returned as written, without case normalization, as the conventions ask. | ||
| // | ||
| // The store issues one hand-written statement per call, so a keyword scan is | ||
| // enough: the table follows INTO for INSERT, UPDATE for UPDATE, TABLE for | ||
| // LOCK, and the first FROM for SELECT and DELETE. A statement that reads | ||
| // several tables (any JOIN), none (SELECT clock_timestamp()) or only a | ||
| // subquery's (SELECT EXISTS(SELECT 1 FROM t)) has no collection, and DDL and | ||
| // transaction control report only their keyword. | ||
| func querySummary(sql string) (operation, collection string) { | ||
| fields := strings.Fields(sql) | ||
| if len(fields) == 0 { | ||
| return "", "" | ||
| } | ||
| operation = fields[0] | ||
| var marker string | ||
| switch strings.ToUpper(operation) { | ||
| case "SELECT", "DELETE": | ||
| marker = "FROM" | ||
| case "INSERT": | ||
| marker = "INTO" | ||
| case "LOCK": | ||
| marker = "TABLE" | ||
| case "UPDATE": | ||
| return operation, collectionToken(fields, 1) | ||
| default: | ||
| return operation, "" | ||
| } | ||
| at := -1 | ||
| for i, f := range fields[1:] { | ||
| switch { | ||
| case strings.EqualFold(f, "JOIN"): | ||
| return operation, "" | ||
| case at < 0 && opensSubquery(f): | ||
| // A subquery opened before the marker owns the first FROM, | ||
| // as in SELECT EXISTS(SELECT 1 FROM t): the outer statement | ||
| // reads no table of its own. | ||
| return operation, "" | ||
| case at < 0 && strings.EqualFold(f, marker): | ||
| at = i + 2 | ||
| } | ||
| } | ||
|
Comment on lines
+143
to
+156
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This takes the first If there is a nested
Collaborator
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 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. |
||
| if at < 0 { | ||
| return operation, "" | ||
| } | ||
| return operation, collectionToken(fields, at) | ||
| } | ||
|
|
||
| // opensSubquery reports whether a whitespace-delimited token starts a nested | ||
| // SELECT: "SELECT", "(SELECT" or "EXISTS(SELECT". | ||
| func opensSubquery(tok string) bool { | ||
| return strings.EqualFold(tok[strings.LastIndexByte(tok, '(')+1:], "SELECT") | ||
| } | ||
|
|
||
| // collectionToken returns fields[i] stripped of surrounding punctuation, or | ||
| // "" when it is absent or is not an identifier (a subquery, for instance). | ||
| func collectionToken(fields []string, i int) string { | ||
| if i >= len(fields) { | ||
| return "" | ||
| } | ||
| tok := strings.Trim(fields[i], "(),;") | ||
| if tok == "" || strings.EqualFold(tok, "SELECT") { | ||
| return "" | ||
| } | ||
| return tok | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This guard has no test, I deleted it, replaced it with
trace.SpanFromContext(ctx), and every test still passed. Can you add theRecordOnlytest so it is covered?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Added TestQueryTracerLeavesRecordOnlyParentOpen. It uses a small sampler that returns RecordOnly, so the parent is recording but not sampled. The statement is skipped, and the test checks the parent is still recording after TraceQueryEnd and that nothing was ended. I tried your change with SpanFromContext and the test fails on both checks, so the guard is covered now.