Skip to content

feat: PPL OpenTelemetry tracing integration - #5708

Draft
penghuo wants to merge 1 commit into
opensearch-project:mainfrom
penghuo:feat/ppl-otel-tracing
Draft

feat: PPL OpenTelemetry tracing integration#5708
penghuo wants to merge 1 commit into
opensearch-project:mainfrom
penghuo:feat/ppl-otel-tracing

Conversation

@penghuo

@penghuo penghuo commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator

Description

Add distributed OpenTelemetry tracing across the PPL Calcite query execution pipeline, reusing the existing query-profiling boundaries so a phase means the same thing whether you read it from profile output or from a trace. When the telemetry feature flag is off, NoopTracer is used with near-zero overhead.

Spans emitted per PPL query

opensearch.query
  ├── opensearch.query.prepare  (INTERNAL, trace-only — covers parse + AST build + anonymize)
  ├── opensearch.query.analyze  (INTERNAL — analyze + Calcite plan + rule-based optimize)
  ├── opensearch.query.optimize (INTERNAL — physical shuttle + prepareStatement)
  └── opensearch.query.execute  (INTERNAL — executeQuery + buildResultSet)

Root-span attributes follow OTel DB semantic conventions:

  • db.system.name=opensearch
  • db.query.type=ppl
  • db.query.id (UUID)
  • db.operation.name (EXECUTE / EXPLAIN)
  • db.query.text — anonymized PPL query

Related Issues

Resolves #5300

Check List

  • New functionality includes testing.
  • New functionality has been documented.
    • New functionality has javadoc added.
    • New functionality has a user manual doc added.
  • New PPL command checklist all confirmed. (N/A — no new PPL command)
  • API changes companion pull request created. (N/A — no API change)
  • Commits are signed per the DCO using --signoff or -s.
  • Public documentation issue/PR created.

By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.

@github-actions

github-actions Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

PR Code Analyzer ❗

AI-powered 'Code-Diff-Analyzer' found issues on commit 3efa12e.

PathLineSeverityDescription
integ-test/build.gradle221highNew dependency added: 'io.opentelemetry.proto:opentelemetry-proto:1.3.2-alpha'. Per mandatory rule, all dependency additions must be flagged regardless of apparent legitimacy — maintainers must verify artifact authenticity.
integ-test/build.gradle397highBuild task downloads a plugin zip from an external URL (artifacts.opensearch.org), extracts it, rewrites the plugin-descriptor.properties to override the OpenSearch version, and repackages it. Modifying a downloaded artifact before installation subverts version integrity checks and is a supply chain risk if the remote URL or local build environment is compromised.
integ-test/build.gradle78highAt configuration time, a live HTTP connection is opened to ci.opensearch.org/maven2 to fetch maven-metadata.xml and resolve the plugin version. Network-dependent version resolution during Gradle configuration is a supply chain risk: a compromised or spoofed response silently changes which artifact version is downloaded and installed into the test cluster.
integ-test/src/test/java/org/opensearch/sql/calcite/tracing/OtlpHttpTraceReceiver.java62mediumOtlpHttpTraceReceiver binds HttpServer to 0.0.0.0 (all interfaces) on a configurable port (default 4318). In a shared CI environment this exposes an unauthenticated HTTP endpoint on all network interfaces for the duration of integration tests, allowing any host-reachable client to inject arbitrary span data or probe the receiver.
plugin/src/main/java/org/opensearch/sql/plugin/transport/TransportPPLQueryAction.java252mediumThe anonymized query text is written as a span attribute ('db.query.text') and exported via OTLP to whatever collector endpoint is configured. Even after anonymization, query structure and operator sequences can reveal schema information. This data is exported to an external endpoint (http://localhost:4317 by default) that may be controlled outside the cluster boundary.
core/src/main/java/org/opensearch/sql/monitor/profile/ProfileScope.java32lowProfileScope.installListener writes to a static volatile field with no access controls, allowing any code with classloader visibility to replace the global PhaseListener — including with a malicious implementation that exfiltrates phase names and timing data. The install point is a single call-site today but the API is broadly accessible.

The table above displays the top 10 most important findings.

Total: 6 | Critical: 0 | High: 3 | Medium: 2 | Low: 1


Pull Requests Author(s): Please update your Pull Request according to the report above.

Repository Maintainer(s): You can bypass diff analyzer by adding label skip-diff-analyzer after reviewing the changes carefully, then re-run failed actions. To re-enable the analyzer, remove the label, then re-run all actions.


⚠️ Note: The Code-Diff-Analyzer helps protect against potentially harmful code patterns. Please ensure you have thoroughly reviewed the changes beforehand.

Thanks.

@penghuo
penghuo force-pushed the feat/ppl-otel-tracing branch 2 times, most recently from be86ef0 to 3bdabc0 Compare August 20, 2026 15:53
@penghuo penghuo added PPL Piped processing language enhancement New feature or request labels Aug 20, 2026
@penghuo
penghuo force-pushed the feat/ppl-otel-tracing branch from 3bdabc0 to 85ae0c4 Compare August 20, 2026 16:44
Add distributed tracing spans across the PPL Calcite query execution
pipeline, reusing the existing query-profiling boundaries so a phase
means the same thing whether you read it from `profile` output or from a
trace. When the telemetry feature flag is off, NoopTracer is used with
near-zero overhead.

Spans emitted per PPL query:

  opensearch.query (CLIENT, root)
    -> opensearch.query.prepare (INTERNAL, trace-only)
    -> opensearch.query.analyze (INTERNAL)
    -> opensearch.query.optimize (INTERNAL)
    -> opensearch.query.execute (INTERNAL)

Root-span attributes follow OTel DB semantic conventions:
db.system.name, db.query.type=ppl, db.query.id (UUID),
db.operation.name (EXECUTE/EXPLAIN), and db.query.text (anonymized via
PPLQueryDataAnonymizer). PPLService hands the anonymized text to a
Consumer supplied by the transport action, which owns the Span.

Module boundaries: core and ppl must not depend on the opensearch
module, so neither references org.opensearch.telemetry. Instead core
gains a PhaseListener interface and a ProfileScope helper that times one
boundary and feeds both a ProfileMetric and — when a listener is
installed — a span. The opensearch module supplies TracingPhaseListener,
backed by Tracer, which TransportPPLQueryAction installs at
construction. Tracer reaches the transport action via the node injector
and is bound into the plugin's child injector by OpenSearchPluginModule.

Profile metric definitions are preserved from main -- the golden rule
is: do not be misled by internal function names, follow the profile's
existing semantics. Each phase's MetricName marker is placed where main
already had its manual System.nanoTime() timing, just wrapped in a
ProfileScope so the trace span shares the same boundary.

  ANALYZE  - QueryService.executeWithCalcite: analyze() + convert +
             CalciteToolsHelper.optimize() (Calcite rule pass).
  OPTIMIZE - CalciteToolsHelper.OpenSearchRelRunners.run: shuttle pass
             (LogicalTableScan -> BindableTableScan) + prepareStatement.
             The name looks like it should mean "compile"; per main's
             profile it is called OPTIMIZE. Left as-is.
  EXECUTE  - OpenSearchExecutionEngine.execute: executeQuery() +
             buildResultSet(). Materialize is inside EXECUTE, matching
             main; there is no separate MATERIALIZE phase.
  FORMAT   - unchanged, populated by QueryService.analyze onResponse.

The phase span name is derived once from MetricName.name().toLowerCase
(ROOT) -- the same rule QueryProfile uses -- so a span name and its
profile phase key cannot drift apart. To keep the trace tree flat under
root, OpenSearchRelRunners.run (OPTIMIZE) is called BEFORE the EXECUTE
ProfileScope opens in OpenSearchExecutionEngine.execute, so OPTIMIZE
closes as a sibling of EXECUTE rather than nesting inside it.

Async lifecycle: the root Span is created on the transport thread and
ended on the worker thread by core's TraceableActionListener; the
SpanScope closes synchronously on the transport thread once
execute()/explain() returns.

The EXECUTE scope closes (records its metric, ends its span) BEFORE
listener.onResponse fires: onResponse drives the downstream pipeline
that snapshots the profile via QueryProfiling.finish(), so recording
after that snapshot would drop the metric while still emitting the span
-- the exact profile/trace divergence this design prevents.

Two hierarchy fixes verified against the observability stack:

  * The analyze endpoint (also profile:true) re-runs the compile pass on
    the caller thread to capture the physical plan text via a Calcite
    hook. That produced a second `compile` span per query while the
    profile stayed accurate because QueryProfiling.noop() was set. Added
    ProfileScope.withSuppression, a thread-local mute for BOTH metric
    and span; wrapped the re-run in it. One compile span per query now.

  * The explain path opened its ANALYZE ProfileScope around the entire
    operation, including the call into executionEngine.explain which
    opens its own compile span. That made `compile` a grandchild of the
    root instead of a sibling. Narrowed the analyze scope to just the
    parse-to-plan work; explain traces now show the flat root ->
    {analyze, compile} shape.

Also added a trace-only `opensearch.query.prepare` span in PPLService,
covering parse + AST build + anonymize on the transport thread before
the sql-worker hop. Without it, cold-start ANTLR grammar init (several
hundred ms) showed up as an unlabeled gap between the root span start
and analyze. It is trace-only because QueryProfiling isn't active on
the transport thread yet -- the profile output is unaffected. Verified
against the observability stack: post-prepare gap dropped from tens to
hundreds of ms cold and 2-8ms warm to <1ms in both cases; the prepare
span itself now shows the actual parse cost (~350ms cold, 1-3ms warm).

Adds a `-DenableTelemetry` toggle to the plugin `run` gradle task that
installs the telemetry-otel plugin from a local OpenSearch source tree
(path required via `-DtelemetryOtelSrc=<path>`), enables the tracer,
sets sampling to 100%, and wires OtlpGrpcSpanExporter to
http://localhost:4317 for the observability-stack docker-compose.

Docs: local-run runbook under docs/dev/.

Tested end-to-end against opensearch-project/observability-stack. Spans
land in Trace Analytics nested under OpenSearch's own REST and transport
spans, with the execute span parenting the downstream shard-search
spans, which confirms context propagation survives the fork-thread
handoff. Per-phase span durations match the profile output within
System.nanoTime() noise, and failures record status.code=2 with the
exception message on the root span.

Signed-off-by: Peng Huo <penghuo@gmail.com>
@penghuo
penghuo force-pushed the feat/ppl-otel-tracing branch from 85ae0c4 to 3efa12e Compare August 20, 2026 16:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request PPL Piped processing language

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[RFC] OpenSearch SQL/PPL Telemetry Integration

1 participant