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.
- flyteplugins/otel/__init__.py +49 -0
- flyteplugins/otel/_ids.py +59 -0
- flyteplugins/otel/_observer.py +259 -0
- flyteplugins/otel/_setup.py +337 -0
- flyteplugins/otel/grafana.py +110 -0
- flyteplugins_otel-2.6.1.dist-info/METADATA +234 -0
- flyteplugins_otel-2.6.1.dist-info/RECORD +9 -0
- flyteplugins_otel-2.6.1.dist-info/WHEEL +5 -0
- flyteplugins_otel-2.6.1.dist-info/top_level.txt +1 -0
|
@@ -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 @@
|
|
|
1
|
+
flyteplugins
|