Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,14 @@ CI supports Python 3.11–3.14; root CI also spans Linux, macOS, and Windows.

- Handler/decorator/OpenAI/ADK telemetry uses the internal logged-step path and converts completed steps to immutable
OTel spans. The internal trace envelope is never exported as a span.
- Explicit `conclude()` outputs on direct operations under declared envelopes travel as span-local trace attributes.
Never replay completed spans or change the frozen declaration to carry those outputs to siblings.
- Manual `start_trace()` freezes supplied fields privately; only direct real operation children carry `splunk_ao.trace.*`.
Keep operation input separate. Internal integrations use unmarked `_start_trace_envelope()` bookkeeping; low-level
`add_trace()` remains unmarked. Public `created_at`, `duration_ns`, and `external_id` are deprecated and ignored.
- Preserve bounded content-free execution-local trace claims across logger conclusion/termination. Reject known local
redeclarations; automatic decorator continuations must neither redeclare nor replay content. Do not add ownership
propagation or a global trace registry. Tests must isolate the claim ContextVar together with OTel/session context.
- `start_splunk_ao_span()` is SDK-native OTel. `add_splunk_ao_span_processor()` and A2A instrument caller-owned OTel.
`configure_distributed_tracing()` is the combined automatic HTTP setup: it creates or accepts an application-owned
provider, registers Splunk AO export once per provider, configures supported upstream instrumentors, and returns the
Expand Down
21 changes: 21 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,27 @@ Path 3: external OTel/OpenInference + A2A
internal trace/step model. Stable OTel context is associated with actual operations. Completed steps are converted to
OTel spans; the `LoggedTrace` envelope remains an internal lifecycle container and must not become a wire span.

Manual `start_trace()` and independent top-level decorators freeze supplied trace fields into a private immutable
snapshot, excluded from legacy hook model dumps. Actual operations directly parented by that envelope carry identical
`splunk_ao.trace.*` attributes; descendants do not. Conversion adds them before constructing `ReadableSpan`, without
changing operation input, IDs, timing, parentage, or sampling. Snapshots are released with their envelope.
Backend trace projection requires compatible new Go OTel ingestion; no trace CRUD request is added.

Explicit `conclude()` output and redacted output are serialized as a span-local `splunk_ao.trace.*` overlay when the
finishing operation is directly parented by a declared envelope. They do not change the frozen start declaration,
later siblings, or operation content. Nested operations and implicit child-output fallback do not declare trace output.
Envelope-only conclusion cannot update already exported spans, and no span is replayed or delayed for that purpose.

Internal integration envelopes use `_start_trace_envelope()` without a declaration. The low-level `add_trace()` and
single-LLM convenience path remain unmarked. Deprecated public `start_trace()` timing and external-ID arguments are
ignored; private integration timing and actual operation timing remain supported.

One content-free trace-ID claim per execution context detects known local redeclarations across logger instances and
local conclusion. Automatic decorator continuations open unmarked envelopes, without replaying the original snapshot.
The claim is not cleared by a logger's conclusion or termination. Independent calls without a remaining upstream parent
create fresh traces. This is bounded local detection, not global ownership: origins declare once and downstream services
continue spans. No baggage, propagation, global trace registry, provider replacement, or sampling change is involved.

Do not bypass this path when extending an existing handler. It centralizes lifecycle behavior, content conversion,
session handling, and compatibility hooks. Conversion changes must cover workflow, agent, LLM, tool, retriever, and
Agent Control kinds that they affect.
Expand Down
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,25 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- Manual `start_trace()` fields are frozen and carried as dedicated `splunk_ao.trace.*` attributes on direct real
operation spans, separate from operation input. Requires compatible new Go OTel ingestion for backend projection.
Top-level decorators include mapped metadata/tags. Known local same-trace redeclarations are rejected; automatic
integration/decorator continuation envelopes remain unmarked. No artificial span or CRUD request is introduced.
- Explicit `conclude()` output and redacted output are carried as trace attributes when a direct real operation
under a declared envelope concludes. Envelope-only conclusion cannot update previously exported spans.

### Deprecated

- Public `start_trace(created_at=..., duration_ns=..., external_id=...)` accepts but ignores these values and emits
`DeprecationWarning`, including hook-backed callers. Actual operation timing APIs are unchanged; use metadata for
business identifiers. Declare trace fields only at the origin, not again in downstream distributed services.

### Fixed

- Workflow and agent decorators preserve mapped metadata and tags on their operation spans, including continuations.

## [0.7.0]

### Added
Expand Down
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,38 @@ for normal scheduled export. `terminate()` drains completed spans and shuts
down resources owned by `SplunkAOLogger`; it does not turn unfinished manual
spans into completed spans.

`start_trace()` opens an internal envelope, not an artificial root span or a
backend CRUD resource. It freezes supplied trace `name`, `input`,
`redacted_input`, `metadata`, `tags`, `dataset_input`, `dataset_output`, and
`dataset_metadata`. Real operations directly below the envelope carry these
values as `splunk_ao.trace.*` attributes; nested operations retain only their
own fields. Trace input never replaces an operation's input. Explicit empty
values are preserved; later edits to supplied collections do not change the
declaration. Backend trace-field projection requires compatible new Go-based
OTel ingestion support; older ingestion routes are not supported for this feature.

When `conclude(output=..., redacted_output=...)` ends a real operation directly below a declared envelope, explicitly
supplied outputs also travel as `splunk_ao.trace.output` and `splunk_ao.trace.redacted_output` on that operation.
Nested operations and inherited child outputs do not declare trace output. Concluding only the envelope cannot deliver
new output fields after its operation spans have been exported. These attributes require compatible backend ingestion.

Declare fields once in the originating application. A duplicate active call,
or a redeclaration of the same OTel trace known to the local execution context,
raises `ValueError`. `conclude()` ends local bookkeeping, not a distributed
trace. Downstream services should continue operations without redeclaring trace
fields. Automatic integration envelopes do not declare fields; independent
top-level decorators declare once, then use unmarked envelopes for known local
continuations. No ownership marker is propagated, so the SDK cannot detect
redeclarations in independent services or execution contexts. Propagated worker
contexts can share a trace ID even across threads.

The `start_trace()` arguments `created_at`, `duration_ns`, and `external_id`
are deprecated and ignored, including with an `ingestion_hook`. Actual operation
timing APIs are unchanged. The backend uses the OTel trace ID for correlation;
put business identifiers in metadata instead. No artificial span, sampling
override, or separate network call is added. Without an exported real operation,
the declaration is not delivered.

#### Using Agent Observability context with Agent Control

If you use Agent Control hosted by Splunk, initialize Agent Control with the
Expand Down
8 changes: 7 additions & 1 deletion src/splunk_ao/converter/span_converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,14 @@
from __future__ import annotations

import time
from collections.abc import Mapping
from datetime import UTC, datetime

from opentelemetry.sdk.resources import Resource
from opentelemetry.sdk.trace import ReadableSpan
from opentelemetry.sdk.util.instrumentation import InstrumentationScope
from opentelemetry.trace import SpanContext, SpanKind, Status, StatusCode
from opentelemetry.util.types import AttributeValue

from galileo_core.schemas.logging.step import BaseStep, StepType
from splunk_ao.converter.attribute_mapping import build_span_attributes
Expand Down Expand Up @@ -85,17 +87,21 @@ def convert_span(
session_id: str | None,
resource: Resource,
end_time_ns: int | None = None,
trace_attributes: Mapping[str, AttributeValue] | None = None,
) -> ReadableSpan:
"""Build a readable span using the supplied identity and resource."""
step_type = _step_type(span)
start_time_ns = _to_unix_ns(span.created_at)
attributes = build_span_attributes(span, session_id)
if trace_attributes:
attributes.update(trace_attributes)

return ReadableSpan(
name=_span_name(span, step_type),
context=span_context,
parent=parent_span_context,
resource=resource,
attributes=build_span_attributes(span, session_id),
attributes=attributes,
events=(),
links=(),
kind=_KIND_BY_STEP_TYPE[step_type],
Expand Down
25 changes: 22 additions & 3 deletions src/splunk_ao/decorator.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ def call_llm(prompt, temperature=0.7):
from splunk_ao.constants import LoggerModeType
from splunk_ao.logger import SplunkAOLogger
from splunk_ao.logger.logger import _otel_context_state
from splunk_ao.logger.trace_metadata import has_trace_declaration
from splunk_ao.schema.content_blocks import is_content_block_list
from splunk_ao.schema.datasets import DatasetRecord
from splunk_ao.schema.metrics import LocalMetricConfig
Expand Down Expand Up @@ -817,9 +818,16 @@ def _prepare_call(
if not isinstance(trace, Trace):
raise RuntimeError("Active Splunk AO operation does not have a trace root")
else:
trace = client_instance.start_trace(
# Independent calls declare once; known distributed continuations
# need only local bookkeeping, never a second trace declaration.
start_trace = (
client_instance._start_trace_envelope if has_trace_declaration() else client_instance.start_trace
)
trace = start_trace(
input=input_,
name=name,
metadata=span_params.get("metadata"),
tags=span_params.get("tags"),
# TODO: add dataset_row_id
dataset_input=dataset_record.input if dataset_record else None,
dataset_output=dataset_record.output if dataset_record else None,
Expand Down Expand Up @@ -847,10 +855,21 @@ def _prepare_call(
if span_type == "agent":
agent_type = span_params.get("agent_type")
span = client_instance.add_agent_span(
input=input_, name=name, agent_type=agent_type, created_at=created_at
input=input_,
name=name,
agent_type=agent_type,
created_at=created_at,
metadata=span_params.get("metadata"),
tags=span_params.get("tags"),
)
else:
span = client_instance.add_workflow_span(input=input_, name=name, created_at=created_at)
span = client_instance.add_workflow_span(
input=input_,
name=name,
created_at=created_at,
metadata=span_params.get("metadata"),
tags=span_params.get("tags"),
)
_get_or_init_list(_span_stack_context).append(span)
elif span_type == "tool":
# A tool must be active while its function runs so nested calls inherit it.
Expand Down
2 changes: 1 addition & 1 deletion src/splunk_ao/handlers/base_handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -252,7 +252,7 @@ def _start_owned_root(self, root_node: Node) -> None:
return

if self._owned_trace is None:
self._owned_trace = self._splunk_ao_logger.start_trace(
self._owned_trace = self._splunk_ao_logger._start_trace_envelope(
input=SplunkAOLogger._coerce_output(root_node.span_params.get("input", "")),
name=root_node.span_params.get("name"),
metadata=root_node.span_params.get("metadata"),
Expand Down
2 changes: 1 addition & 1 deletion src/splunk_ao/handlers/openai_agents/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ def on_trace_start(self, trace: Trace) -> None:
self._trace_states[trace_id] = state
if state.caller_parent is None:
try:
state.owned_trace = self._splunk_ao_logger.start_trace(
state.owned_trace = self._splunk_ao_logger._start_trace_envelope(
input="Agent Workflow",
name="Trace",
created_at=datetime.fromisoformat(node.span_params["start_time_iso"]),
Expand Down
Loading
Loading