Skip to content

feat(dbm): opt-in full propagation for prepared statements (DD_DBM_TRACE_PREPARED_STATEMENTS) - #4211

Open
bray-lula wants to merge 2 commits into
DataDog:masterfrom
bray-lula:bray-lula/dbm-trace-prepared-statements
Open

bray-lula wants to merge 2 commits into
DataDog:masterfrom
bray-lula:bray-lula/dbm-trace-prepared-statements

Conversation

@bray-lula

Copy link
Copy Markdown

Description

Adds DD_DBM_TRACE_PREPARED_STATEMENTS (INI datadog.dbm_trace_prepared_statements, boolean, default false).

When it is enabled and DD_DBM_PROPAGATION_MODE=full, the prepare hooks (PDO::prepare, mysqli::prepare,
mysqli_prepare) stop downgrading to service mode on the full-propagation backends (mysql, pgsql). The
prepared statement then carries a traceparent exactly as a non-prepared statement does, and the prepare span
carries _dd.dbm_trace_injected. Nothing else changes: the default is off, backends outside
$fullPropagationBackends still downgrade, and no non-prepared code path reads the option.

The whole behaviour change is one condition in DatabaseIntegrationHelper::injectDatabaseIntegrationData():

if (
    $propagationMode == \DDTrace\DBM_PROPAGATION_FULL
    && (!isset($fullPropagationBackends[$backend])
        || ($preventFullMode && !\dd_trace_env_config("DD_DBM_TRACE_PREPARED_STATEMENTS")))
) {
    $propagationMode = \DDTrace\DBM_PROPAGATION_SERVICE;
}

Addresses #2993.

What this unlocks

Since #3545, an application whose framework prepares every statement gets service-mode Database Monitoring
out of a full-mode configuration. Laravel's query builder is the common case (PDO::prepare followed by
PDOStatement::execute for every query); Doctrine DBAL and most PDO wrappers behave the same way. For those
applications, with this option:

full today full + this option
Comment on a prepared statement dddbs, dde, ddps, ddpv the same, plus traceparent
DBM query sample trace.mode service full
DBM sample → APM caller service name only a specific trace and span
APM span → DBM none the PDO.prepare / mysqli.prepare span shows the linked sample and its explain plan
Query Metrics calling services by service name by service name (unchanged)

The backend joins on the (trace_id, span_id) in the comment and copies the resolved span's operation,
service, duration, peer.db.name and peer.hostname onto the sample. So a sample from a prepare-everything
application goes from "came from service X" to "came from this request, this span, this statement". That is the
piece #3545 removed for these applications, and it has had no replacement since.

Background

#3545 made the prepare hooks use service mode, and 7f6cc09 turned that into the $preventFullMode parameter
this option gates. The reasoning was correct: the comment is written while the prepare span is active, the
statement runs later in a sibling span, so the propagated span id was not the span that executed the query.

The side effect is that a framework which prepares unconditionally loses span-level linking for all of its
queries, not just for a few. #2993 shows the symptom without a diagnosis ("I have set env
DD_DBM_PROPAGATION_MODE=full on my app but i don't have trace") and went stale. On one Laravel service we run
against MySQL 8 with full configured, every one of the roughly 12,000 prepared-statement executions DBM
sampled in a day records "mode":"service".

Every release before #3545 propagated the prepare span's context for prepared statements. This PR does not
revert #3545; it puts that earlier behaviour behind an explicit opt-in, for applications that would rather have
the prepare span than nothing. The default and the three #3545 tests are untouched.

Design: why the prepare span's own context

There are three candidates for the span id to put in the comment at prepare time.

  1. The execute span. It does not exist yet, and PHP has no writable span id (SpanData exposes only
    hexId()), so it cannot be pre-allocated at prepare time the way dd-trace-java does for SQL Server. That
    would need extension-level work and is out of scope here.

  2. The parent span, as the common ancestor of prepare and execute. I built this first and it is wrong, for a
    reason visible in what the backend does with the link. A full-mode sample resolves to this shape (ids and
    names replaced):

    "trace": {
      "mode": "full", "trace_id": "<trace id>",
      "span": { "id": "<span id>", "operation": "mysql.query", "service": "<db service>",
                "duration": 417747168, "peer_db_name": "<database>", "peer_hostname": "<host>" },
      "caller": { "service": "<app service>" }, "root": { "service": "<app service>", "resource": "<route>" }
    }
    

    The ids decode from the comment's traceparent, and the fields copied onto the sample are database-shaped.
    A web.request or controller span has no peer.* tags and its duration is the whole request, so the
    sample would attach to a non-database span showing a plausible but wrong duration. It would also break an
    invariant every tracer keeps today: the span tagged _dd.dbm_trace_injected is the span whose id is in the
    comment, and it is always the SQL span (java StatementInstrumentation.java:128-135, py
    _database_monitoring.py:143-145, go contrib/database/sql/conn.go:124, dotnet DbScopeFactory.cs:115-121,
    rb sql_comment.rb:26, js plugins/database.js:133-136).

  3. The prepare span. It is span.type=sql, its service is the integration's, it carries the connection's
    peer.* tags, and Obfuscate the :name placeholders in PDO away for DBM correlation #3801 already keeps its resource equal to the statement DBM sees. It is the same trace and
    the same statement, one sibling from the span that executed it. This is what the PR does, and it is what
    every release before feat(PDO): correct a bug on prepared statement regarding DBM correlation #3545 did.

Trade-offs accepted

These are the costs of enabling the option. Each one is why it is opt-in rather than a change to the default.

  1. The linked span is prepare, not execute. DBM displays the prepare span's duration. With client-side
    (emulated) prepares that is microseconds; with server-side prepares it is one round trip. The execution time
    is on the sibling PDOStatement.execute / mysqli_stmt.execute span in the same trace, one click away, but
    it is not what the sample shows. This is exactly the imprecision feat(PDO): correct a bug on prepared statement regarding DBM correlation #3545 removed. The option makes accepting it
    the application's decision instead of the tracer's.

  2. Statement reuse links every execution to the prepare-time trace. Prepare once, execute N times: N
    executions carry one traceparent. For per-request prepare-and-execute (the frameworks above) this is a
    non-issue. For prepared-statement caches in long-running workers (Octane, Swoole, RoadRunner, FrankenPHP
    worker mode) or persistent connections, later executions can link to a trace that has already finished. The
    proposed docs text says so.

  3. The sampling decision is forced at the first prepare. generate_distributed_tracing_headers() forces
    the sampling decision on the active span (tracer/handlers_http.h), as it already does for every
    non-prepared statement in full mode. With the option on, that now also happens on the first prepare in a
    trace. For an application that already runs full with any non-prepared statement, nothing new. For a pure
    prepare-everything application, full mode starts costing what full mode costs everywhere else.

  4. One config lookup per prepare in full mode. dd_trace_env_config() is the right operand of &&,
    reached only on a prepare that was already about to downgrade, on a path that already runs a regex and
    builds the comment string. Off, or outside full, or on a non-prepared statement, the lookup does not run.

  5. Same name as dd-trace-java, different mechanism. The name follows dd-trace-java 1.44+, the precedent
    Allow appending SQL comments via DD_DBM_ALWAYS_APPEND_SQL_COMMENT #3954 used ("Modeled after dd-trace-java#9798"). The mechanism is not the same and the PR should not be read
    as parity:

    dd-trace-java this PR
    Injection point execute prepare
    Channel connection.setClientInfo("ApplicationName", "_DD_" + traceparent) SQL comment
    Databases Postgres only mysql, pgsql
    Linked span the execute span the prepare span
    Cost one extra round trip per execute; RDS Proxy connection pinning none

    Java's channel is not available to PHP without a different feature (a SET application_name per execute
    is a round trip and Postgres-only). The docs text below states the difference up front so the confusion that
    appeared on dd-trace-java#7940 does not repeat.

Limitations that remain between DBM and PDO

These are not introduced by the PR and it does not address them. They determine where a user will see a
result after enabling the option, so reviewers should have them in front of them.

  1. MySQL server-side prepared statements are invisible to DBM, comment or not. MySQL records a native
    prepare as statement/com/Prepare and its execution as statement/com/Execute, both with NULL SQL_TEXT
    and NULL DIGEST_TEXT; they never enter events_statements_summary_by_digest, and the Agent's statement
    sampler filters exactly those rows (integrations-core, mysql/statement_samples.py:
    WHERE sql_text IS NOT NULL ... AND digest_text IS NOT NULL). Two consequences. On MySQL over PDO, this
    option has a DBM-visible effect only with PDO::ATTR_EMULATE_PREPARES => true (PDO's default, which
    Laravel and Doctrine keep). mysqli prepared statements are always server-side, so for mysqli the comment
    and the tag are correct but the sample never exists for them to link to. Confirmed on MySQL 8.0 by reading
    the general log and performance_schema side by side. This is a MySQL and Agent property; the tracer cannot
    change it.
  2. Exact execute-span linkage needs an id that does not exist at prepare time. Pre-allocating the execute
    span's id, or making a span id writable, is extension work and a separate proposal. This PR takes the
    sibling span rather than no span.
  3. Postgres plan and digest cardinality is not measured (next section).
  4. dynamic_service as the fallback target. Add dynamic_service DBM propagation mode #3940 left open whether "when full is not possible" should
    fall back to dynamic_service rather than service. This option gates whether the prepare downgrade
    happens, not what it downgrades to, so if that is later changed this condition needs no change.

Plan cache and digest cardinality (#1983)

#1983 kept full mode to mysql and pgsql because "full context propagation messes with the query plan
caching" on mssql. A traceparent makes every prepare textually unique, so the question applies here.

Measured on MySQL 8.0: 50 traces each preparing and executing the same statement, so 50 distinct traceparent
values, counting performance_schema.events_statements_summary_by_digest:

Configuration Distinct digests Executions counted
DBM disabled 3 52
full, option off 3 52
full, option on 3 52

Identical. MySQL normalises comments out of the digest, and MySQL 8.0 has no text-keyed plan cache; the
behaviour #1983 describes is a SQL Server property, and sqlsrv stays outside $fullPropagationBackends and
outside this option (asserted by a test). Server-side statement reuse is unaffected either way: each
PDO::prepare() creates its own handle regardless of the comment.

Postgres was not measured. pg_stat_statements computes its query id from the parse tree, so comments
should not affect it, but I have not verified that and do not claim it.

Why this cannot regress an existing user

With the option off, the code takes exactly the path it takes today. The added dd_trace_env_config read is
the right operand of &&, reached only on a prepare that was already about to downgrade in full mode on a
full-propagation backend. Every other call site (exec, query, execute_query, mysqli_query,
real_query, sqlsrv_query) passes $preventFullMode = false and never reaches it.

Verified, not asserted:

  • Every DBM mode, option on and off, driving a real PDO::prepare and capturing the rewritten query.
    disabled, service and dynamic_service are byte-identical either way. In full, the only delta is the
    added traceparent. DD_DBM_ALWAYS_APPEND_SQL_COMMENT keeps the comment appended; DD_DBM_INJECT_SQL_BASEHASH
    keeps tag order alphabetical.
  • Two mutations show the tests pin both directions. Forcing the downgrade unconditionally (the pre-PR
    behaviour) fails exactly the three option-on tests (helper, PDO, mysqli) and nothing else. Removing the
    prepared-statement downgrade fails exactly the PDO and mysqli feat(PDO): correct a bug on prepared statement regarding DBM correlation #3545 tests; the sqlsrv feat(PDO): correct a bug on prepared statement regarding DBM correlation #3545 test keeps
    passing because sqlsrv is downgraded by the backend check, which this option does not touch.
  • No public API change. No new method, no widened signature, no renamed parameter.
  • PDOStatement::queryString stays clean. The force_overwrite_property restore from 7f6cc09 is
    mode-independent; the option-on tests assert the prepare and execute resources equal the original statement.
  • mysqli and sqlsrv stash the original query in $hook->data for their post-hooks and have no
    user-facing queryString to restore, so the PDO-specific concern in 7f6cc09 does not arise there.

Tests

Each new test is the #3545 test for that backend with the option on, or the existing helper-level test with
the prepare flag set.

  • tests/Integration/DatabaseMonitoringTest.php (no database): with the option, a prepare-flagged injection
    produces the same output as testInjection (the injecting span's own seeded id, the marker on that span); the
    option is a no-op in service, dynamic_service and disabled.
  • tests/Integrations/PDO/PDOTest.php, tests/Integrations/Mysqli/MysqliTest.php: the prepare span carries
    _dd.dbm_trace_injected, execute remains a sibling, both resources remain the clean statement.
  • tests/Integrations/SQLSRV/SQLSRVTest.php: with the option on, sqlsrv_prepare still has no marker.

PHP 8.3 (debug) and PHP 8.5 (NTS), aarch64, in the repo's docker environment:

Target PHP 8.3 PHP 8.5
make test_integration FILTER=DatabaseMonitoringTest OK (15 tests, 118 assertions) OK (15 tests, 118 assertions)
make test_integrations_pdo OK (38 tests, 1268 assertions, 1 pre-existing skip) OK (38 tests, 1300 assertions, 7 pre-existing risky)
make test_integrations_mysqli OK (31 tests, 1207 assertions) OK (31 tests, 1207 assertions)
make test_integrations_sqlsrv OK (19 tests, 676 assertions) OK (19 tests, 676 assertions)

The skip is testPDOConnectOk (PDO::connect() is PHP 8.4+); the risky tests are the
testParseDsnDbNameQuoteHandling data sets printing output on 8.5. Neither is touched by this PR.

Also run: composer ci-lint, tests/ext/telemetry/config.phpt, tooling/generate-supported-configurations.sh
(regenerated; only the new entry differs). phpcs --standard=phpcs.xml reports the same error count on the
changed files as on master. PDOBench is unaffected: it measures execute() on a statement prepared once in
@BeforeMethods and does not set the option. Not run locally: other PHP versions, Windows, ZTS, musl,
ASAN/valgrind, the full .phpt suite.

Docs

This repository hosts no documentation snippets. Proposed text for the PHP tab of
database_monitoring/connect_dbm_and_apm and the PHP library configuration reference:

DD_DBM_TRACE_PREPARED_STATEMENTS (INI: datadog.dbm_trace_prepared_statements, default false): when
DD_DBM_PROPAGATION_MODE is full, also propagate trace context for prepared statements (PDO::prepare,
mysqli::prepare, mysqli_prepare) on MySQL and PostgreSQL. By default prepared statements fall back to
service mode, because the statement executes after the prepare call and the only context available at
prepare time is the prepare span's. With this option enabled, query samples link to the prepare span: the
same trace and the same statement, one sibling away from the span that executed it, and the sample shows the
prepare span's duration rather than the execution's.

This differs from the option of the same name in the Java tracer, which injects at execution time through the
connection's application_name, links to the execute span, and applies to PostgreSQL only.

A statement prepared once and executed many times carries the trace context of the prepare() call for every
execution. Where statement handles are reused across requests (prepared-statement caches in long-running
workers such as Laravel Octane, Swoole, RoadRunner or FrankenPHP worker mode, or persistent connections),
later executions can link to a trace that has already finished. Enable it where statements are prepared and
executed once per request.

On MySQL the statement must be visible to Database Monitoring to be linked, which requires client-side
prepared statements (PDO::ATTR_EMULATE_PREPARES => true, the PDO default); server-side prepared statements,
including all mysqli prepared statements, are not sampled by the Agent. Not applicable to SQL Server, which
stays in service mode.

Reviewer checklist

  • Test coverage seems ok.
  • Appropriate labels assigned.

bray-lula and others added 2 commits September 16, 2026 13:40
Add DD_DBM_TRACE_PREPARED_STATEMENTS (datadog.dbm_trace_prepared_statements,
default false). When enabled together with DD_DBM_PROPAGATION_MODE=full, the
prepare hooks (PDO::prepare, mysqli::prepare, mysqli_prepare) no longer
downgrade to service mode on the full-propagation backends, so a prepared
statement carries a traceparent like any other statement and the prepare
span carries _dd.dbm_trace_injected.

Since DataDog#3545 an application whose framework prepares every statement gets
service-mode DBM from a full-mode configuration: the comment is written
while the prepare span is active and the statement executes later in a
sibling span, so the tracer stopped propagating. That was the right default
and stays the default. This option lets an application accept the prepare
span as the linked span (same trace, same statement, one sibling from the
execution) rather than have no span-level link at all, which is what every
release before DataDog#3545 did.

The change is one condition in
DatabaseIntegrationHelper::injectDatabaseIntegrationData(): the option is
read as the right operand behind $preventFullMode, so it is only consulted
on a prepare that was already about to downgrade in full mode on mysql or
pgsql. Non-prepared statements, other modes, and backends outside
$fullPropagationBackends never reach it. No public API change.

Tests: the DataDog#3545 tests for PDO, mysqli and sqlsrv are unchanged; each gains
a sibling with the option on (PDO and mysqli: prepare span tagged, execute
still a sibling, resources clean; sqlsrv: still service mode). Two
helper-level cases pin that the comment carries the injecting span's own id
and that the option is a no-op outside full mode.

Addresses DataDog#2993.

🤖 Generated with Claude Code
@bray-lula
bray-lula marked this pull request as ready for review September 17, 2026 16:31
@bray-lula
bray-lula requested review from a team as code owners September 17, 2026 16:31
@bray-lula
bray-lula requested review from tabgok and removed request for a team September 17, 2026 16:31
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.

1 participant