flyteplugins-otel 2.6.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,49 @@
1
+ """OpenTelemetry tracing for Flyte.
2
+
3
+ Every task becomes a span, every `flyte.trace` step becomes a child span inside it, and
4
+ spans created by your own code or by any instrumentation library nest underneath without
5
+ wiring. Export goes wherever OTLP goes.
6
+
7
+ Usage:
8
+
9
+ import flyte
10
+ from flyteplugins.otel import init
11
+
12
+ # At module scope, not inside a task: the task span opens before the task body runs.
13
+ init(service_name="my-service")
14
+
15
+ env = flyte.TaskEnvironment(name="my_env")
16
+
17
+ @env.task
18
+ async def main(n: int) -> int:
19
+ ...
20
+
21
+ With no arguments `init` reads OTEL_EXPORTER_OTLP_ENDPOINT and OTEL_EXPORTER_OTLP_HEADERS,
22
+ so pointing it at a vendor is a matter of setting those usually from a `flyte.Secret`. If
23
+ you already configure OpenTelemetry yourself, pass `tracer_provider` and your setup is
24
+ adopted unchanged.
25
+
26
+ Trace context travels in Flyte's `custom_context` as a W3C carrier in both directions: a
27
+ run submitted inside a caller's span joins that trace, and a child task nests under the task
28
+ that spawned it even though it runs in another pod.
29
+
30
+ Two things are specific to Flyte being durable. When no trace context arrives from outside,
31
+ the trace id is derived from the run identity, so the several processes that make up a
32
+ crashed-and-resumed run all record into one trace with no coordination. And steps that a
33
+ resumed run served from its durable log, which never execute and so would otherwise be
34
+ missing, are recorded as spans marked `flyte.replayed`.
35
+ """
36
+
37
+ from ._ids import format_trace_id, trace_id_for_run
38
+ from ._observer import OtelObserver, RunScopedIdGenerator
39
+ from ._setup import get_tracer, init, shutdown
40
+
41
+ __all__ = [
42
+ "OtelObserver",
43
+ "RunScopedIdGenerator",
44
+ "format_trace_id",
45
+ "get_tracer",
46
+ "init",
47
+ "shutdown",
48
+ "trace_id_for_run",
49
+ ]
@@ -0,0 +1,59 @@
1
+ """Deriving a stable OpenTelemetry trace id from a Flyte run.
2
+
3
+ A durable run can span several containers: it crashes, resumes, retries, and each of those
4
+ is a fresh process with a fresh OpenTelemetry SDK. Left alone, every one of them mints its
5
+ own random trace id and the backend ends up holding several unrelated traces for what the
6
+ user thinks of as a single agent run.
7
+
8
+ Deriving the trace id from the run identifier fixes that without any coordination. Every
9
+ process computes the same 16 bytes from values it already has, so spans recorded before a
10
+ crash and spans recorded after the resume land in the same trace even though neither
11
+ process ever spoke to the other.
12
+
13
+ Only the trace id is derived. Span ids stay random, which keeps each attempt a distinct
14
+ subtree under the shared trace rather than a set of colliding ids, and means a resumed run
15
+ shows its earlier attempts alongside the one that finally succeeded.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import hashlib
21
+ from typing import TYPE_CHECKING
22
+
23
+ if TYPE_CHECKING:
24
+ from flyte.models import ActionID
25
+
26
+ __all__ = ["format_trace_id", "trace_id_for_run"]
27
+
28
+ # Namespace prefix so these digests cannot collide with any other use of the same inputs.
29
+ _NAMESPACE = b"flyte.otel.run.v1"
30
+
31
+ # W3C trace ids are 16 bytes and must not be all zero.
32
+ _TRACE_ID_BYTES = 16
33
+
34
+
35
+ def trace_id_for_run(action: "ActionID") -> int:
36
+ """The trace id shared by every span in this run, across attempts and containers.
37
+
38
+ Derived from the fully qualified run identity rather than the run name alone, so two
39
+ runs that happen to share a name in different projects or domains stay distinct.
40
+ """
41
+ parts = (
42
+ action.org or "",
43
+ action.project or "",
44
+ action.domain or "",
45
+ action.run_name or action.name,
46
+ )
47
+ digest = hashlib.blake2b(
48
+ b"\x00".join(part.encode("utf-8") for part in parts),
49
+ digest_size=_TRACE_ID_BYTES,
50
+ person=_NAMESPACE[:16],
51
+ ).digest()
52
+
53
+ # A zero trace id is invalid per the spec and would be silently dropped by exporters.
54
+ return int.from_bytes(digest, "big") or 1
55
+
56
+
57
+ def format_trace_id(trace_id: int) -> str:
58
+ """Render a trace id the way backends display it: 32 lowercase hex characters."""
59
+ return format(trace_id, "032x")
@@ -0,0 +1,259 @@
1
+ """Turning Flyte's task and trace callbacks into OpenTelemetry spans."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+ import contextvars
7
+ from collections.abc import Generator, Mapping
8
+ from typing import TYPE_CHECKING, Any, Optional
9
+
10
+ import flyte
11
+ from flyte._logging import logger
12
+ from opentelemetry import trace as trace_api
13
+ from opentelemetry.context import Context
14
+ from opentelemetry.propagate import extract, inject
15
+ from opentelemetry.sdk.trace.id_generator import IdGenerator, RandomIdGenerator
16
+ from opentelemetry.trace import Span, SpanKind, Status, StatusCode
17
+
18
+ from ._ids import trace_id_for_run
19
+
20
+ if TYPE_CHECKING:
21
+ from flyte._observe import Recorder, StepInfo, TaskInfo
22
+ from flyte.models import ActionID
23
+
24
+ __all__ = ["OtelObserver", "RunScopedIdGenerator"]
25
+
26
+ # Set for the instant a root span is being created, so the id generator below can hand back
27
+ # the run's derived trace id instead of a random one. A ContextVar rather than a plain
28
+ # attribute because tasks may start spans from several coroutines on the same thread.
29
+ _pinned_trace_id: contextvars.ContextVar[Optional[int]] = contextvars.ContextVar("flyte_pinned_trace_id", default=None)
30
+
31
+
32
+ class RunScopedIdGenerator(IdGenerator):
33
+ """Hands back the run's derived trace id when one has been pinned.
34
+
35
+ OpenTelemetry gives no way to ask for a specific trace id when starting a span; the
36
+ tracer always asks its provider's id generator. Pinning the value around the one call
37
+ that needs it is the supported way in.
38
+
39
+ Everything else is delegated, so wrapping a provider that was configured elsewhere keeps
40
+ whatever id generation it already had.
41
+ """
42
+
43
+ def __init__(self, delegate: IdGenerator | None = None):
44
+ self._delegate = delegate or RandomIdGenerator()
45
+
46
+ def generate_span_id(self) -> int:
47
+ return self._delegate.generate_span_id()
48
+
49
+ def generate_trace_id(self) -> int:
50
+ pinned = _pinned_trace_id.get()
51
+ return pinned if pinned is not None else self._delegate.generate_trace_id()
52
+
53
+
54
+ def _action_attributes(action: "ActionID") -> dict[str, Any]:
55
+ """The Flyte identifiers that let a span be traced back to the run that produced it.
56
+
57
+ These are also what a Grafana data link queries on to jump from a span back into the
58
+ Flyte UI, so the names here are effectively public API.
59
+ """
60
+ attributes: dict[str, Any] = {"flyte.action_name": action.name}
61
+ if action.run_name:
62
+ attributes["flyte.run_name"] = action.run_name
63
+ if action.project:
64
+ attributes["flyte.project"] = action.project
65
+ if action.domain:
66
+ attributes["flyte.domain"] = action.domain
67
+ if action.org:
68
+ attributes["flyte.org"] = action.org
69
+ return attributes
70
+
71
+
72
+ def _inbound_context(carrier: Mapping[str, str] | None) -> Context | None:
73
+ """Read a parent span context out of a Flyte custom_context carrier.
74
+
75
+ This is the same W3C carrier the documented `custom_context` propagation pattern uses,
76
+ so a run submitted inside a caller's span joins that caller's trace rather than starting
77
+ its own. Returns None when the carrier holds nothing usable, which is the ordinary case
78
+ for a run kicked off without any surrounding trace.
79
+ """
80
+ if not carrier:
81
+ return None
82
+ try:
83
+ context = extract(dict(carrier))
84
+ except Exception:
85
+ logger.debug("Could not extract a trace context from custom_context", exc_info=True)
86
+ return None
87
+ span_context = trace_api.get_current_span(context).get_span_context()
88
+ return context if span_context.is_valid else None
89
+
90
+
91
+ @contextlib.contextmanager
92
+ def _propagate_to_sub_actions() -> Generator[None, None, None]:
93
+ """Publish the active span into custom_context for the duration of the block.
94
+
95
+ Best effort: propagation failing is not a reason to fail the task, and outside a task
96
+ context `flyte.custom_context` is itself a no-op.
97
+ """
98
+ carrier: dict[str, str] = {}
99
+ try:
100
+ inject(carrier)
101
+ except Exception:
102
+ logger.debug("Could not inject a trace context for sub-actions", exc_info=True)
103
+
104
+ if not carrier:
105
+ yield
106
+ return
107
+
108
+ # Driven by hand rather than with `with`, so that only entering and leaving the context
109
+ # are guarded. Wrapping the body too would swallow the task's own exceptions.
110
+ manager = None
111
+ try:
112
+ manager = flyte.custom_context(**carrier)
113
+ manager.__enter__()
114
+ except Exception:
115
+ logger.debug("Could not set custom_context for sub-actions", exc_info=True)
116
+ manager = None
117
+
118
+ try:
119
+ yield
120
+ finally:
121
+ if manager is not None:
122
+ try:
123
+ manager.__exit__(None, None, None)
124
+ except Exception:
125
+ logger.debug("Could not restore custom_context after the task", exc_info=True)
126
+
127
+
128
+ def _finish(span: Span, recorder: "Recorder") -> None:
129
+ """Stamp the outcome on a span before it ends.
130
+
131
+ `flyte.trace` swallows the user's exception so it can be written to the durable log,
132
+ so the error arrives on the recorder rather than propagating through the span's own
133
+ exception handling.
134
+ """
135
+ error = recorder.error
136
+ if error is None:
137
+ span.set_status(Status(StatusCode.OK))
138
+ return
139
+ span.record_exception(error)
140
+ span.set_status(Status(StatusCode.ERROR, f"{type(error).__name__}: {error}"))
141
+
142
+
143
+ class OtelObserver:
144
+ """A `flyte._observe.Observer` that records spans.
145
+
146
+ Task spans are roots pinned to the run's derived trace id. Every attempt of a task
147
+ therefore starts its own subtree and because the trace id is shared those subtrees all
148
+ collect into the one trace: a resumed run reads as the attempt that crashed followed by
149
+ the attempt that finished.
150
+ """
151
+
152
+ def __init__(self, tracer: trace_api.Tracer):
153
+ self._tracer = tracer
154
+
155
+ @contextlib.contextmanager
156
+ def task_span(self, info: "TaskInfo", recorder: "Recorder") -> Generator[None, None, None]:
157
+ span = self._start_task_span(info)
158
+
159
+ with trace_api.use_span(span, end_on_exit=True, record_exception=False, set_status_on_exception=False):
160
+ # Hand this span down as the parent for anything spawned inside the task. Flyte
161
+ # copies custom_context into every sub-action's inputs, so a child task running in
162
+ # another pod picks the parent up with no wiring of our own, and because inputs are
163
+ # durable it survives a resume too.
164
+ with _propagate_to_sub_actions():
165
+ try:
166
+ yield
167
+ finally:
168
+ _finish(span, recorder)
169
+
170
+ def _start_task_span(self, info: "TaskInfo") -> Span:
171
+ """Start the task span under an inbound trace when there is one.
172
+
173
+ A `traceparent` in the run's context means something outside Flyte already started
174
+ this trace: the caller who submitted the run or the parent task that spawned this
175
+ one. Nesting under it is what joins the two halves, and it keeps working across a
176
+ resume because the carrier travels with the action's persisted inputs.
177
+
178
+ With nothing inbound this is a root span, and the trace id is derived from the run so
179
+ that separate attempts still converge on one trace.
180
+ """
181
+ attributes = {**_action_attributes(info.action), "flyte.task_name": info.name}
182
+ parent = _inbound_context(info.custom_context)
183
+
184
+ if parent is not None:
185
+ return self._tracer.start_span(
186
+ info.name,
187
+ context=parent,
188
+ kind=SpanKind.INTERNAL,
189
+ attributes=attributes,
190
+ record_exception=False,
191
+ set_status_on_exception=False,
192
+ )
193
+
194
+ token = _pinned_trace_id.set(trace_id_for_run(info.action))
195
+ try:
196
+ # An empty Context makes this a root span, which is what forces the tracer to
197
+ # mint a trace id at all, and therefore to consult the pinned value.
198
+ return self._tracer.start_span(
199
+ info.name,
200
+ context=Context(),
201
+ kind=SpanKind.INTERNAL,
202
+ attributes=attributes,
203
+ record_exception=False,
204
+ set_status_on_exception=False,
205
+ )
206
+ finally:
207
+ _pinned_trace_id.reset(token)
208
+
209
+ @contextlib.contextmanager
210
+ def step_span(self, info: "StepInfo", recorder: "Recorder") -> Generator[None, None, None]:
211
+ attributes = {
212
+ **_action_attributes(info.action),
213
+ "flyte.step_name": info.name,
214
+ "flyte.replayed": info.replayed,
215
+ }
216
+ if info.task_action is not None:
217
+ attributes["flyte.task_action_name"] = info.task_action.name
218
+
219
+ # A replayed step opens and closes this block without doing any work, so it lands as
220
+ # a near-instant span. That is the honest shape: the step did not run here, it was
221
+ # read back from the durable log, and flyte.replayed says so.
222
+ with self._tracer.start_as_current_span(
223
+ info.name,
224
+ kind=SpanKind.INTERNAL,
225
+ attributes=attributes,
226
+ record_exception=False,
227
+ set_status_on_exception=False,
228
+ ) as span:
229
+ try:
230
+ yield
231
+ finally:
232
+ _finish(span, recorder)
233
+
234
+
235
+ def make_id_generator(existing: Optional[IdGenerator] = None) -> IdGenerator:
236
+ """Return an id generator that honours pinned trace ids, wrapping one if given."""
237
+ if isinstance(existing, RunScopedIdGenerator):
238
+ return existing
239
+ return RunScopedIdGenerator(existing)
240
+
241
+
242
+ def adopt_provider(tracer_provider: object) -> bool:
243
+ """Make an already configured TracerProvider able to honour run derived trace ids.
244
+
245
+ Someone who set up their own provider should not have to give it up to get durable traces.
246
+ Swapping in a wrapping id generator leaves their sampler, resource and exporters exactly as they were.
247
+
248
+ Returns whether the swap happened. A provider without an `id_generator` (the API's
249
+ no-op provider, or a stub) is left alone and simply gets random trace ids.
250
+ """
251
+ existing = getattr(tracer_provider, "id_generator", None)
252
+ if existing is None:
253
+ return False
254
+ try:
255
+ tracer_provider.id_generator = make_id_generator(existing) # type: ignore[attr-defined]
256
+ except Exception:
257
+ logger.debug("Could not install a run scoped id generator on the supplied provider", exc_info=True)
258
+ return False
259
+ return True
@@ -0,0 +1,337 @@
1
+ """Wiring the observer up to an exporter.
2
+
3
+ OTLP is the default because it is what most backends document but nothing here requires it.
4
+ Any `SpanExporter` works, several can run side by side, and a provider you configured
5
+ yourself is adopted whole.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import threading
12
+ from collections.abc import Sequence
13
+ from typing import Any, Mapping, Optional, Union
14
+
15
+ import flyte._observe as observe
16
+ from flyte._logging import logger
17
+ from opentelemetry import trace as trace_api
18
+ from opentelemetry.sdk.resources import Resource
19
+ from opentelemetry.sdk.trace import TracerProvider
20
+ from opentelemetry.sdk.trace.export import BatchSpanProcessor, SimpleSpanProcessor, SpanExporter
21
+
22
+ from ._observer import OtelObserver, adopt_provider, make_id_generator
23
+
24
+ __all__ = ["get_tracer", "init", "shutdown"]
25
+
26
+ _INSTRUMENTATION_NAME = "flyteplugins-otel"
27
+
28
+ # Process-global, not context-local, and deliberately so: init installs an OpenTelemetry
29
+ # TracerProvider (a process global itself) and appends to flyte._observe's module-level
30
+ # observer list. Context-local state here would let shutdown miss an observer that is still
31
+ # firing for every task. The lock makes the check-then-set in init atomic, matching how
32
+ # flyte's own _ControllerState guards the same shape.
33
+ _lock = threading.Lock()
34
+ _state: dict[str, Any] = {"provider": None, "observer": None, "tracer": None}
35
+
36
+
37
+ def _normalize_headers(headers: Union[Mapping[str, str], str, None]) -> Optional[dict[str, str]]:
38
+ """Accept either a mapping or the comma separated form used by the OTEL_ env vars."""
39
+ if headers is None or isinstance(headers, dict):
40
+ return dict(headers) if headers else None
41
+ if isinstance(headers, Mapping):
42
+ return dict(headers)
43
+ parsed: dict[str, str] = {}
44
+ for pair in str(headers).split(","):
45
+ if not pair.strip():
46
+ continue
47
+ key, _, value = pair.partition("=")
48
+ parsed[key.strip()] = value.strip()
49
+ return parsed or None
50
+
51
+
52
+ def _traces_endpoint(endpoint: Optional[str]) -> Optional[str]:
53
+ """Point an OTLP gateway base URL at its traces path.
54
+
55
+ Backends hand out a base URL (Grafana Cloud gives you the OTLP gateway), while the HTTP
56
+ exporter wants the signal specific path when the endpoint is passed explicitly. Doing
57
+ the append here means the value copied out of a vendor console works as-is.
58
+ """
59
+ if not endpoint:
60
+ return None
61
+ trimmed = endpoint.rstrip("/")
62
+ if trimmed.endswith("/v1/traces"):
63
+ return trimmed
64
+ return f"{trimmed}/v1/traces"
65
+
66
+
67
+ def _resolve_protocol(protocol: Optional[str]) -> str:
68
+ """Pick the OTLP wire protocol, honouring OpenTelemetry's own environment variable.
69
+
70
+ The spec spells these "http/protobuf" and "grpc"; the short forms are accepted because
71
+ that is what people type. Ignoring OTEL_EXPORTER_OTLP_PROTOCOL would mean a backend
72
+ configured entirely through the standard variables silently got the wrong transport.
73
+ """
74
+ raw = (
75
+ protocol
76
+ or os.environ.get("OTEL_EXPORTER_OTLP_TRACES_PROTOCOL")
77
+ or os.environ.get("OTEL_EXPORTER_OTLP_PROTOCOL")
78
+ )
79
+ value = (raw or "http/protobuf").strip().lower()
80
+ if value == "grpc":
81
+ return "grpc"
82
+ if value in ("http", "http/protobuf", "httpprotobuf", "http/proto"):
83
+ return "http/protobuf"
84
+ raise ValueError(f"Unsupported OTLP protocol {raw!r}. Use 'http/protobuf' or 'grpc'.")
85
+
86
+
87
+ def _otlp_exporter(
88
+ *,
89
+ protocol: str,
90
+ endpoint: Optional[str],
91
+ headers: Union[Mapping[str, str], str, None],
92
+ ) -> SpanExporter:
93
+ """Build the OTLP exporter for the chosen transport.
94
+
95
+ gRPC lives in a separate distribution, so its absence is reported as something to install
96
+ rather than an ImportError from deep inside the SDK. Note the endpoint differs by
97
+ transport: gRPC takes the base endpoint, HTTP wants the signal specific path.
98
+ """
99
+ normalized_headers = _normalize_headers(headers)
100
+ if protocol == "grpc":
101
+ try:
102
+ from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import OTLPSpanExporter as GrpcExporter
103
+ except ImportError as e:
104
+ raise ImportError(
105
+ "OTLP over gRPC needs the opentelemetry-exporter-otlp-proto-grpc package. "
106
+ 'Install it with `pip install "flyteplugins-otel[grpc]"`, or use protocol="http/protobuf".'
107
+ ) from e
108
+ return GrpcExporter(endpoint=endpoint or None, headers=normalized_headers)
109
+
110
+ from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter as HttpExporter
111
+
112
+ return HttpExporter(endpoint=_traces_endpoint(endpoint), headers=normalized_headers)
113
+
114
+
115
+ def _otlp_endpoint_configured(endpoint: Optional[str]) -> bool:
116
+ """Whether anyone actually said where traces should go.
117
+
118
+ Without this the OTLP exporter silently falls back to the spec default of
119
+ http://localhost:4318, and every batch then produces several lines of connection-refused
120
+ retries. That is noise rather than information, and it is the normal state of the driver
121
+ process, which imports the module to submit a run and has no reason to export anything.
122
+ """
123
+ return bool(
124
+ endpoint
125
+ or os.environ.get("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT")
126
+ or os.environ.get("OTEL_EXPORTER_OTLP_ENDPOINT")
127
+ )
128
+
129
+
130
+ def _as_exporters(exporter: Union[SpanExporter, Sequence[SpanExporter], None]) -> list[SpanExporter]:
131
+ """Accept one exporter or several. A single SpanExporter is not itself a sequence."""
132
+ if exporter is None:
133
+ return []
134
+ if isinstance(exporter, SpanExporter):
135
+ return [exporter]
136
+ return list(exporter)
137
+
138
+
139
+ def _warn_if_task_already_started() -> None:
140
+ """Say something when init lands too late to record the task's own span.
141
+
142
+ The task span opens before the task body runs, so an observer registered from inside the
143
+ body has already missed it. The symptom is a trace holding the trace steps but no task
144
+ span to hang them from, which is hard to work out from the output alone.
145
+ """
146
+ try:
147
+ from flyte._context import internal_ctx
148
+
149
+ # Not `flyte.ctx() is not None`: outside a task that returns a falsy NullTaskContext
150
+ # rather than None, so the identity check would report every call as being in a task.
151
+ in_task = internal_ctx().is_task_context()
152
+ except Exception: # pragma: no cover - context lookup should not break init
153
+ return
154
+
155
+ if in_task:
156
+ logger.warning(
157
+ "flyteplugins-otel was initialized from inside a running task, so that task's own span "
158
+ "has already started and will not be recorded. Call init() at module scope instead, so "
159
+ "the observer is registered before any task begins."
160
+ )
161
+
162
+
163
+ def init(
164
+ *,
165
+ service_name: Optional[str] = None,
166
+ endpoint: Optional[str] = None,
167
+ headers: Union[Mapping[str, str], str, None] = None,
168
+ protocol: Optional[str] = None,
169
+ resource_attributes: Optional[Mapping[str, Any]] = None,
170
+ exporter: Union[SpanExporter, Sequence[SpanExporter], None] = None,
171
+ tracer_provider: Optional[trace_api.TracerProvider] = None,
172
+ disable_batch: bool = False,
173
+ set_global: bool = True,
174
+ ) -> OtelObserver:
175
+ """Start recording Flyte tasks and trace steps as OpenTelemetry spans.
176
+
177
+ Call this once at module scope, not inside a task. The task span opens before the task
178
+ body runs, so initializing from within the body means that task's own span has already
179
+ been missed. Module scope runs during import, which happens before any task starts.
180
+
181
+ With no arguments it reads the standard OTEL_EXPORTER_OTLP_ENDPOINT and
182
+ OTEL_EXPORTER_OTLP_HEADERS variables, which is the shape most vendors document.
183
+
184
+ If you already configure OpenTelemetry yourself, pass `tracer_provider` and none of the
185
+ exporter arguments. The provider is adopted as it stands, with its sampler, resource, and
186
+ exporters untouched; only its id generator is wrapped, so run derived trace ids keep
187
+ working without you giving up your own setup.
188
+
189
+ Args:
190
+ service_name: Value for service.name. Defaults to OTEL_SERVICE_NAME, then "flyte".
191
+ Not used when adopting a provider, which carries its own resource.
192
+ endpoint: OTLP endpoint. On http/protobuf a base gateway URL is fine and the traces
193
+ path is added; on grpc the base endpoint is used as given.
194
+ headers: Export headers, as a mapping or the "k=v,k2=v2" form.
195
+ protocol: OTLP transport, "http/protobuf" or "grpc". Defaults to
196
+ OTEL_EXPORTER_OTLP_TRACES_PROTOCOL, then OTEL_EXPORTER_OTLP_PROTOCOL, then
197
+ http/protobuf. gRPC needs the [grpc] extra.
198
+ resource_attributes: Extra resource attributes to attach to every span.
199
+ exporter: Export through this instead of building an OTLP exporter, or pass several
200
+ to fan out — a ConsoleSpanExporter alongside a real backend, say. Any
201
+ `SpanExporter` works; nothing here requires OTLP.
202
+ tracer_provider: Adopt a provider you configured yourself instead of building one.
203
+ Cannot be combined with the exporter building arguments.
204
+ disable_batch: Export each span as it ends. Slower, but nothing is lost if the
205
+ process dies, which matters when the thing being demonstrated is a crash.
206
+ set_global: Install the provider as the global one, so other instrumentation shares
207
+ it. Not used when adopting a provider, which is assumed to be installed already.
208
+
209
+ Returns:
210
+ The registered observer, which can be passed to `shutdown`.
211
+
212
+ Raises:
213
+ ValueError: If tracer_provider is combined with the exporter building arguments.
214
+ """
215
+ with _lock:
216
+ if _state["observer"] is not None:
217
+ logger.debug("flyteplugins-otel already initialized; returning the existing observer")
218
+ return _state["observer"]
219
+
220
+ return _init_locked(
221
+ service_name=service_name,
222
+ endpoint=endpoint,
223
+ headers=headers,
224
+ protocol=protocol,
225
+ resource_attributes=resource_attributes,
226
+ exporter=exporter,
227
+ tracer_provider=tracer_provider,
228
+ disable_batch=disable_batch,
229
+ set_global=set_global,
230
+ )
231
+
232
+
233
+ def _init_locked(
234
+ *,
235
+ service_name: Optional[str],
236
+ endpoint: Optional[str],
237
+ headers: Union[Mapping[str, str], str, None],
238
+ protocol: Optional[str],
239
+ resource_attributes: Optional[Mapping[str, Any]],
240
+ exporter: Union[SpanExporter, Sequence[SpanExporter], None],
241
+ tracer_provider: Optional[trace_api.TracerProvider],
242
+ disable_batch: bool,
243
+ set_global: bool,
244
+ ) -> OtelObserver:
245
+ """The body of `init` run with the lock already held."""
246
+ _warn_if_task_already_started()
247
+
248
+ if tracer_provider is not None:
249
+ conflicting = {
250
+ "endpoint": endpoint,
251
+ "headers": headers,
252
+ "exporter": exporter,
253
+ "protocol": protocol,
254
+ "resource_attributes": resource_attributes,
255
+ }
256
+ supplied = sorted(name for name, value in conflicting.items() if value is not None)
257
+ if supplied:
258
+ raise ValueError(
259
+ f"tracer_provider cannot be combined with {', '.join(supplied)}: the supplied provider "
260
+ f"already owns its exporters and resource. Configure them on the provider instead."
261
+ )
262
+ return _attach(tracer_provider, owns_provider=False)
263
+
264
+ attributes: dict[str, Any] = {
265
+ "service.name": service_name or os.environ.get("OTEL_SERVICE_NAME") or "flyte",
266
+ }
267
+ if resource_attributes:
268
+ attributes.update(resource_attributes)
269
+
270
+ provider = TracerProvider(resource=Resource.create(attributes), id_generator=make_id_generator())
271
+
272
+ exporters = _as_exporters(exporter)
273
+ if not exporters:
274
+ if _otlp_endpoint_configured(endpoint):
275
+ exporters = [_otlp_exporter(protocol=_resolve_protocol(protocol), endpoint=endpoint, headers=headers)]
276
+ else:
277
+ # Say it once, here, rather than let it surface as a retry storm from the
278
+ # exporter. Spans are still recorded, so nesting and context propagation behave
279
+ # exactly as they would; they are simply dropped instead of shipped.
280
+ logger.warning(
281
+ "flyteplugins-otel: no OTLP endpoint configured, so spans are recorded but not exported. "
282
+ "Set OTEL_EXPORTER_OTLP_ENDPOINT, pass endpoint=, or pass an exporter such as "
283
+ "ConsoleSpanExporter. If you are running a local collector, point the variable at it "
284
+ "explicitly (http://localhost:4318) rather than relying on the default."
285
+ )
286
+
287
+ # One processor per exporter, so several can run side by side.
288
+ for each in exporters:
289
+ provider.add_span_processor(SimpleSpanProcessor(each) if disable_batch else BatchSpanProcessor(each))
290
+
291
+ if set_global:
292
+ trace_api.set_tracer_provider(provider)
293
+
294
+ return _attach(provider, owns_provider=True)
295
+
296
+
297
+ def _attach(provider: trace_api.TracerProvider, *, owns_provider: bool) -> OtelObserver:
298
+ """Register an observer against a provider, remembering whether we may shut it down."""
299
+ if not owns_provider and not adopt_provider(provider):
300
+ logger.debug(
301
+ "The supplied tracer provider has no id generator to wrap. Spans are still recorded, but trace "
302
+ "ids will not be derived from the run, so separate attempts of a run will not share a trace."
303
+ )
304
+
305
+ tracer = provider.get_tracer(_INSTRUMENTATION_NAME)
306
+ observer = OtelObserver(tracer)
307
+ observe.register_observer(observer)
308
+
309
+ # Only a provider we built is ours to shut down; adopting one must not flush or close it.
310
+ _state.update({"provider": provider if owns_provider else None, "observer": observer, "tracer": tracer})
311
+ return observer
312
+
313
+
314
+ def get_tracer() -> Optional[trace_api.Tracer]:
315
+ """The tracer `init` built for handing to another instrumentation library.
316
+
317
+ Libraries that create their own provider still nest correctly since parenting comes from
318
+ the active context rather than the provider. Sharing the tracer just keeps everything on
319
+ one export pipeline.
320
+ """
321
+ return _state["tracer"]
322
+
323
+
324
+ def shutdown() -> None:
325
+ """Unregister the observer and flush pending spans."""
326
+ with _lock:
327
+ _shutdown_locked()
328
+
329
+
330
+ def _shutdown_locked() -> None:
331
+ observer = _state["observer"]
332
+ if observer is not None:
333
+ observe.unregister_observer(observer)
334
+ provider = _state["provider"]
335
+ if provider is not None:
336
+ provider.shutdown()
337
+ _state.update({"provider": None, "observer": None, "tracer": None})
@@ -0,0 +1,110 @@
1
+ """Links from a Flyte action into Grafana.
2
+
3
+ Flyte renders a task's `links` in its UI, so this is the jump from a run to the telemetry
4
+ it produced. These are pure URL builders with no Grafana dependency, and they work off the
5
+ `flyte.*` span attributes this plugin already stamps.
6
+
7
+ The trace link queries rather than addressing a trace by id. That matters: a query on
8
+ `flyte.run_name` finds a run's spans whatever their trace ids turn out to be, so it works
9
+ before the run-derived trace id is involved, and keeps working after. Addressing by id would
10
+ depend on the derivation and break the moment anything upstream propagated a trace context.
11
+
12
+ Only the Tempo link lives here. A link to a run's conversation in Grafana Agent Observability
13
+ belongs with `flyteplugins-agento11y`, since it is that package's identity binding that
14
+ makes a run addressable by conversation id at all.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import re
21
+ from dataclasses import dataclass
22
+ from typing import Dict, Optional
23
+ from urllib.parse import urlencode
24
+
25
+ from flyte import Link
26
+
27
+ __all__ = ["GrafanaTrace"]
28
+
29
+ # Flyte hands links placeholders like {{.runName}} at serialization time and substitutes them
30
+ # by string replacement on the finished URI. Percent-encoding turns "{{" into "%7B%7B", which
31
+ # that replacement never matches, so the placeholder would reach Grafana verbatim and the
32
+ # query would search for a run literally named "{{.runName}}".
33
+ _ENCODED_TEMPLATE = re.compile(r"%7B%7B([.\w]+)%7D%7D")
34
+
35
+
36
+ def _encode_preserving_templates(params: Dict[str, object]) -> str:
37
+ """urlencode, then put Flyte's template placeholders back in literal form."""
38
+ return _ENCODED_TEMPLATE.sub(r"{{\1}}", urlencode(params))
39
+
40
+
41
+ def _explore_url(*, host: str, datasource_uid: str, query: str, time_from: str, time_to: str) -> str:
42
+ """Build a Grafana Explore deep link running a TraceQL query.
43
+
44
+ Explore encodes its state as a JSON `panes` parameter. The time range has to be part of
45
+ it because Explore otherwise defaults to the last hour, and a link to yesterday's run
46
+ would open on an empty pane and read as broken.
47
+ """
48
+ panes = {
49
+ "flyte": {
50
+ "datasource": datasource_uid,
51
+ "queries": [
52
+ {
53
+ "refId": "A",
54
+ "datasource": {"type": "tempo", "uid": datasource_uid},
55
+ "queryType": "traceql",
56
+ "query": query,
57
+ }
58
+ ],
59
+ "range": {"from": time_from, "to": time_to},
60
+ }
61
+ }
62
+ params = _encode_preserving_templates(
63
+ {"schemaVersion": 1, "panes": json.dumps(panes, separators=(",", ":")), "orgId": 1}
64
+ )
65
+ return f"{host.rstrip('/')}/explore?{params}"
66
+
67
+
68
+ @dataclass
69
+ class GrafanaTrace(Link):
70
+ """A link to this run's spans in Grafana Tempo.
71
+
72
+ Args:
73
+ host: Stack URL, e.g. `https://myorg.grafana.net`.
74
+ datasource_uid: UID of the Tempo datasource. Per-stack and not guessable, so there is
75
+ no useful default; find it under Connections, Data sources in Grafana.
76
+ name: Label shown in the Flyte UI.
77
+ lookback: Start of the Explore time range, in Grafana's relative syntax. The link
78
+ protocol gives no timestamps, so the window is relative and deliberately wide.
79
+ action_scoped: Narrow the query to the single action rather than the whole run.
80
+ """
81
+
82
+ host: str
83
+ datasource_uid: str
84
+ name: str = "Grafana trace"
85
+ icon_uri: Optional[str] = ""
86
+ lookback: str = "now-7d"
87
+ action_scoped: bool = False
88
+
89
+ def get_link(
90
+ self,
91
+ run_name: str,
92
+ project: str,
93
+ domain: str,
94
+ context: Dict[str, str],
95
+ parent_action_name: str,
96
+ action_name: str,
97
+ pod_name: str,
98
+ **kwargs,
99
+ ) -> str:
100
+ selectors = [f'.flyte.run_name="{run_name}"']
101
+ if self.action_scoped and action_name:
102
+ selectors.append(f'.flyte.action_name="{action_name}"')
103
+ query = "{" + " && ".join(selectors) + "}"
104
+ return _explore_url(
105
+ host=self.host,
106
+ datasource_uid=self.datasource_uid,
107
+ query=query,
108
+ time_from=self.lookback,
109
+ time_to="now",
110
+ )
@@ -0,0 +1,234 @@
1
+ Metadata-Version: 2.4
2
+ Name: flyteplugins-otel
3
+ Version: 2.6.1
4
+ Summary: OpenTelemetry tracing for Flyte, with traces that survive durable resume
5
+ Author-email: Samhita Alla <samhita@union.ai>
6
+ Requires-Python: >=3.10
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: opentelemetry-api>=1.20.0
9
+ Requires-Dist: opentelemetry-sdk>=1.20.0
10
+ Requires-Dist: opentelemetry-exporter-otlp-proto-http>=1.20.0
11
+ Requires-Dist: flyte
12
+ Provides-Extra: grpc
13
+ Requires-Dist: opentelemetry-exporter-otlp-proto-grpc>=1.20.0; extra == "grpc"
14
+
15
+ # flyteplugins-otel
16
+
17
+ OpenTelemetry tracing for Flyte.
18
+
19
+ Every task becomes a span, every `flyte.trace` step becomes a child span inside it, and spans
20
+ created by your own code or by any instrumentation library nest underneath without wiring.
21
+ Export goes wherever OTLP goes.
22
+
23
+ None of this is specific to agents or to LLM work. It is ordinary distributed tracing for
24
+ ordinary Flyte tasks with some additions for the things Flyte does that a normal
25
+ OpenTelemetry setup has no way to model.
26
+
27
+ ## Install and use
28
+
29
+ ```python
30
+ import flyte
31
+ from flyteplugins.otel import init
32
+
33
+ # At module scope, not inside a task. The task span opens before the task body runs, so
34
+ # initializing from within the body means that task's own span has already been missed.
35
+ init(service_name="my-service")
36
+
37
+ env = flyte.TaskEnvironment(name="my_env")
38
+
39
+ @env.task
40
+ async def main(n: int) -> int:
41
+ ...
42
+ ```
43
+
44
+ With no arguments `init` reads the standard `OTEL_EXPORTER_OTLP_ENDPOINT` and
45
+ `OTEL_EXPORTER_OTLP_HEADERS` variables, which is the shape most vendors document. Supply them
46
+ as a `flyte.Secret` rather than hardcoding them.
47
+
48
+ A base gateway URL is fine if you pass the endpoint directly; the traces path is appended:
49
+
50
+ ```python
51
+ init(
52
+ service_name="my-service",
53
+ endpoint="https://otlp-gateway-prod-us-east-0.grafana.net/otlp",
54
+ headers={"Authorization": "Basic <base64>"},
55
+ )
56
+ ```
57
+
58
+ ### Exporters
59
+
60
+ OTLP is the default but not a requirement. Any `SpanExporter` works and several can run side by
61
+ side — a console exporter alongside a real backend, say:
62
+
63
+ ```python
64
+ init(exporter=[ConsoleSpanExporter(), JaegerExporter(...)])
65
+ ```
66
+
67
+ For OTLP, both transports are supported. The protocol follows
68
+ `OTEL_EXPORTER_OTLP_TRACES_PROTOCOL`, then `OTEL_EXPORTER_OTLP_PROTOCOL`, then
69
+ `http/protobuf`, or pass it directly. gRPC ships separately:
70
+
71
+ ```bash
72
+ pip install "flyteplugins-otel[grpc]"
73
+ ```
74
+
75
+ ```python
76
+ init(endpoint="http://collector:4317", protocol="grpc")
77
+ ```
78
+
79
+ If you already configure OpenTelemetry yourself, hand over the provider instead and nothing
80
+ about your setup changes:
81
+
82
+ ```python
83
+ init(tracer_provider=my_provider)
84
+ ```
85
+
86
+ ## Examples
87
+
88
+ | Example | What it covers |
89
+ | -------------------------- | ------------------------------------------------------- |
90
+ | `basic_tracing.py` | The smallest setup, printing spans to the console |
91
+ | `custom_spans.py` | Your own spans inside a task, nesting automatically |
92
+ | `nested_tasks.py` | Tasks calling tasks, across pods, in one trace |
93
+ | `existing_provider.py` | Adopting a TracerProvider you already configured |
94
+ | `propagate_from_caller.py` | Joining a trace that started outside Flyte |
95
+ | `http_instrumentation.py` | Third party auto-instrumentation, here httpx |
96
+ | `grafana_cloud.py` | Exporting to a real backend, with secrets |
97
+ | `durable_trace.py` | A crash and its resume, as a single trace |
98
+
99
+ ## Trace context, in and out
100
+
101
+ Flyte propagates a key-value `custom_context` through a run and into every sub-action. This
102
+ plugin uses it as a W3C carrier in both directions.
103
+
104
+ Inbound: if `custom_context` holds a `traceparent`, the task span starts under it rather than
105
+ becoming a root. So a run submitted from inside a caller's span joins the caller's trace:
106
+
107
+ ```python
108
+ with tracer.start_as_current_span("incoming_request"):
109
+ carrier = {}
110
+ inject(carrier)
111
+ run = flyte.with_runcontext(custom_context=carrier).run(main, ...)
112
+ ```
113
+
114
+ Outbound: once the task span is open, the plugin publishes it back into `custom_context`, so
115
+ a child task running in another pod nests under the task that spawned it with nothing passed
116
+ by hand. Because `custom_context` travels in the action's persisted inputs, this survives a
117
+ resume too.
118
+
119
+ Which means the manual `extract` inside each task is no longer needed just to get spans
120
+ parented correctly. Reach for it when you want to start your own spans under the incoming
121
+ context; the plugin's own spans are already there.
122
+
123
+ ## What durability adds
124
+
125
+ A run that crashes and resumes is several processes over time. Each starts a fresh
126
+ OpenTelemetry SDK that has no idea the earlier ones existed, and two things go wrong.
127
+
128
+ Every attempt mints its own random trace id, so one agent run arrives at the backend as
129
+ several unrelated traces. And every step the resumed run replayed out of its durable log is
130
+ missing entirely, because replayed steps never execute, so nothing instruments them. The
131
+ trace ends up with holes in it exactly where durability did its job.
132
+
133
+ So when there is no inbound trace context, the trace id is derived from the run identity
134
+ rather than generated. Every process computes the same value from information it already has,
135
+ and all of them record into one trace with no coordination. Span ids stay random, so each
136
+ attempt is a distinct subtree: a resumed run reads as the attempt that crashed followed by
137
+ the attempt that finished.
138
+
139
+ Replayed steps are recorded as spans marked `flyte.replayed`. They have no meaningful
140
+ duration, because no work happened, but they are present, so the trace is complete.
141
+
142
+ For a live demo, `disable_batch=True` exports each span as it ends. It is slower, but nothing
143
+ is lost when the process dies, which matters when the thing being demonstrated is a crash.
144
+
145
+ ## Linking back from Grafana
146
+
147
+ `flyteplugins.otel.grafana` builds links from a Flyte action into Grafana, rendered in the
148
+ Flyte UI. They are plain URL builders with no Grafana dependency.
149
+
150
+ ```python
151
+ from flyteplugins.otel.grafana import GrafanaTrace
152
+
153
+ @env.task(links=(GrafanaTrace(host="https://myorg.grafana.net", datasource_uid="<tempo-uid>"),))
154
+ async def my_task() -> str:
155
+ ...
156
+ ```
157
+
158
+ The trace link queries on `flyte.run_name` rather than addressing a trace by id, so it finds
159
+ a run's spans whatever their trace ids turn out to be — including runs whose trace context
160
+ came from outside. It embeds a time range because Explore otherwise defaults to the last
161
+ hour, and a link to an older run would open on an empty pane.
162
+
163
+ The datasource UID is per-stack and not guessable; find it under Connections, Data sources.
164
+
165
+ Flyte hands links placeholders such as `{{.runName}}` when the task is serialized and swaps
166
+ them for real values on the finished URI, so the link is built once and works for every run.
167
+ That substitution is a plain string replacement, which means the placeholders have to survive
168
+ URL encoding intact — the link builders keep them literal for exactly this reason.
169
+
170
+ Only the Tempo link lives here. A link to a run's conversation in Grafana Agent
171
+ Observability ships with `flyteplugins-agento11y`, since it is that package's identity
172
+ binding that makes a run addressable by conversation id at all.
173
+
174
+ ## Span attributes
175
+
176
+ Every span carries the identifiers needed to get back to the run that produced it. These are
177
+ effectively public API, since a Grafana data link queries on them to jump from a span into
178
+ the Flyte UI.
179
+
180
+ | Attribute | On | Meaning |
181
+ | -------------------------------------------- | ---------- | ------------------------------------------------- |
182
+ | `flyte.run_name` | all | The run, and what the trace id is derived from |
183
+ | `flyte.action_name` | all | The action that produced the span |
184
+ | `flyte.project`, `flyte.domain`, `flyte.org` | all | Where the run lives |
185
+ | `flyte.task_name` | task spans | The task being executed |
186
+ | `flyte.step_name` | step spans | The traced function |
187
+ | `flyte.task_action_name` | step spans | The task that owns the step |
188
+ | `flyte.replayed` | step spans | Whether this step was served from the durable log |
189
+
190
+ ## Using it alongside other instrumentation
191
+
192
+ Libraries that emit their own spans — the OpenTelemetry instrumentation packages, agent
193
+ observability SDKs, vendor SDKs — need no extra wiring. Parenting comes from the active
194
+ context rather than from the tracer provider, and the task span is active for the whole task
195
+ body, so their spans land inside it.
196
+
197
+ Calling `init` before the other library keeps everything on one export pipeline.
198
+ `get_tracer()` returns the tracer this plugin built for libraries that accept one.
199
+
200
+ ## Flyte's own control-plane calls appear as spans
201
+
202
+ Once tracing is on you will see `POST` client spans for Flyte's calls to the control plane —
203
+ `Enqueue`, `CreateRun`, `UploadInputs` and so on — alongside your own.
204
+
205
+ They do not come from this plugin. Flyte's transport is `pyqwest`, whose `HTTPTransport`
206
+ takes `enable_otel: bool = True` and falls back to the global tracer provider when it is not
207
+ given one. `init(set_global=True)`, the default, installs that provider, so the transport
208
+ starts recording through it.
209
+
210
+ Mostly this is useful: inside a task the spans nest under the task span, so you can see how
211
+ much of a task's wall clock went on talking to the control plane, and the 401-then-200 pairs
212
+ show the auth retry. Two things to be aware of. The volume scales with sub-action count, so a
213
+ wide fan-out produces a lot of them. And calls made outside a task span, during submission,
214
+ arrive as their own root traces rather than joining the run's trace.
215
+
216
+ There is no switch for this in the plugin, since the transport is Flyte's rather than ours.
217
+ `init(set_global=False)` keeps the provider out of the global slot, which stops the transport
218
+ finding it, at the cost of other instrumentation not finding it either.
219
+
220
+ ## Limitations
221
+
222
+ With no OTLP endpoint configured — no `OTEL_EXPORTER_OTLP_ENDPOINT`, no `endpoint=`, no
223
+ explicit exporter — spans are recorded but not exported, and `init` says so once. This is the
224
+ normal state of the process that submits a run, since it imports your module and therefore
225
+ runs `init` without needing to export anything. If you run a local collector, point the
226
+ variable at it explicitly rather than relying on the OTLP default of `localhost:4318`.
227
+
228
+ Replayed spans have no duration. The original timing is written to the control plane but does
229
+ not come back over the channel a resumed run reads from, so recovering it needs a backend
230
+ change. What you get is the step's presence, identity, and outcome.
231
+
232
+ Trace context rides in `custom_context`, which is a flat string map that Flyte propagates
233
+ wholesale. The `traceparent` key is therefore visible to task code and will be overwritten if
234
+ something else writes that key.
@@ -0,0 +1,9 @@
1
+ flyteplugins/otel/__init__.py,sha256=TtOndV9edavTbBokh1RQEnTKTt7TJ1605jwCb-jlVlQ,1808
2
+ flyteplugins/otel/_ids.py,sha256=SDxUBeGTObz9Vaugna3u3t3ncPMwgHEh7r1TjIfYveU,2264
3
+ flyteplugins/otel/_observer.py,sha256=85XYgYUqiWG3mp-j2E1t7hNVr34Fth0OP0MIMiYP9Qg,10468
4
+ flyteplugins/otel/_setup.py,sha256=NXdqEImn7PQn0gMXFqsphkeqvjuhE7TGCBekZcmlJJc,14697
5
+ flyteplugins/otel/grafana.py,sha256=2LEemHdyVliUhjinspMFT0syuyfkY3DdIAEBFcgAZz8,4217
6
+ flyteplugins_otel-2.6.1.dist-info/METADATA,sha256=zo7ZFyqYSMyPvbnxiCXbt-CWVNR6q5d5Qe54vdUhuVc,11206
7
+ flyteplugins_otel-2.6.1.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
8
+ flyteplugins_otel-2.6.1.dist-info/top_level.txt,sha256=cgd779rPu9EsvdtuYgUxNHHgElaQvPn74KhB5XSeMBE,13
9
+ flyteplugins_otel-2.6.1.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ flyteplugins