otelzero 0.2.0__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.
otelzero/hooks/adk.py ADDED
@@ -0,0 +1,128 @@
1
+ """Google ADK instrumentation: switch on the framework's *own* OpenTelemetry.
2
+
3
+ `Google ADK <https://github.com/google/adk-python>`_ always traces itself
4
+ through the global OpenTelemetry tracer provider, in the GenAI semantic
5
+ conventions. A ``Runner`` run opens an ``invoke_workflow {app}`` span. Each
6
+ agent that takes a turn gets an ``invoke_agent {name}`` span under it, with
7
+ ``generate_content {model}`` and ``execute_tool {tool}`` spans nested inside.
8
+ That is more than a wrapper around ``Runner.run_async`` could see, so this hook
9
+ wraps nothing. Instead it:
10
+
11
+ * configures otelzero's OTLP provider as the global one, so ADK's spans export
12
+ to ``{server_url}/v1/traces``;
13
+ * opts ADK into the latest GenAI conventions (``gen_ai_latest_experimental``
14
+ in ``OTEL_SEMCONV_STABILITY_OPT_IN``), which replace ADK's legacy
15
+ ``gcp.vertex.agent.*`` payload attributes with ``gen_ai.input.messages`` /
16
+ ``gen_ai.output.messages`` / ``gen_ai.tool.call.*``;
17
+ * attaches the shared :class:`~otelzero.hooks._native.NativeRunProcessor`,
18
+ which stamps ``user.name`` and ``otelzero.session.name`` on every ADK span
19
+ and turns each ``invoke_agent`` span into ``agent_start`` / ``agent_end`` /
20
+ ``agent_error``. A multi-agent run reports each agent that takes a turn.
21
+
22
+ **Session.** ADK writes its own session id as ``gen_ai.conversation.id`` on
23
+ its spans, and it does so after each span starts, so the id can't be replaced
24
+ there. It is also the right value: an ADK session *is* the conversation, and
25
+ the caller chooses it. So for ADK the dashboard groups runs by ADK's session
26
+ id. The events are filed under it too, so spans and events line up, and the
27
+ ``session=`` argument and ``run_context(conversation_id=...)`` don't apply.
28
+
29
+ **Content.** ADK follows the standard
30
+ ``OTEL_INSTRUMENTATION_GENAI_CAPTURE_MESSAGE_CONTENT`` variable on its own
31
+ terms. ``true`` / ``1`` mean ``EVENT_ONLY`` there, so prompts go to log records,
32
+ not spans. ``SPAN_ONLY`` or ``SPAN_AND_EVENT`` puts them on span attributes,
33
+ where the registry reads them. Unset, no content is recorded.
34
+
35
+ ``google.adk`` may be imported before or after the install call. The ``otel``
36
+ extra is required: the lifecycle events come from spans, so tracing is always
37
+ on even with ``OTELZERO_OTEL=0``.
38
+ """
39
+
40
+ from typing import Optional
41
+
42
+ from otelzero import otel
43
+ from otelzero.hooks import _native
44
+ from otelzero.telemetry import TelemetryClient
45
+
46
+ FRAMEWORK = "Google ADK"
47
+
48
+ # ADK's tracer is ``trace.get_tracer("gcp.vertex.agent")``.
49
+ _SCOPES = ("gcp.vertex.agent", "google.adk")
50
+
51
+ _LATEST = "gen_ai_latest_experimental"
52
+
53
+ def install_adk_monitoring(
54
+ server_url: str = "http://localhost:8000",
55
+ user: Optional[str] = None,
56
+ api_key: Optional[str] = None,
57
+ session: Optional[str] = None,
58
+ name: Optional[str] = None,
59
+ ) -> TelemetryClient:
60
+ """
61
+ Install Google ADK monitoring via the framework's native OpenTelemetry.
62
+
63
+ Points ADK's own spans at ``{server_url}/v1/traces`` (or at
64
+ ``OTEL_EXPORTER_OTLP_ENDPOINT`` when set), opts them into the latest GenAI
65
+ semantic conventions, and sends ``agent_start`` and ``agent_end`` (or
66
+ ``agent_error``) for each agent turn, built from its ``invoke_agent``
67
+ span. May be called before or after ``google.adk`` is imported, but
68
+ requires the ``otel`` extra.
69
+
70
+ Args:
71
+ server_url: URL of the monitoring server
72
+ user: Name of the executing user
73
+ api_key: Ingest key tying events to an owner (for the iOS app)
74
+ session: Fallback session id, used only for a run whose spans carry
75
+ no ADK session id; ADK's own session id groups runs otherwise
76
+ name: Session title shown on the dashboard and apps (no title is
77
+ derived from the prompt)
78
+
79
+ Raises:
80
+ ImportError: ``google.adk`` is not installed.
81
+ RuntimeError: the OpenTelemetry SDK (the ``otel`` extra) is missing.
82
+ """
83
+ try:
84
+ import google.adk # noqa: F401
85
+ except ImportError as exc: # pragma: no cover - exercised by hand
86
+ raise ImportError(
87
+ "install_adk_monitoring needs google.adk importable: "
88
+ "uv pip install google-adk"
89
+ ) from exc
90
+
91
+ telemetry = TelemetryClient(
92
+ server_url,
93
+ framework=FRAMEWORK,
94
+ user=user,
95
+ api_key=api_key,
96
+ session=session,
97
+ name=name,
98
+ )
99
+ provider = _native.start_tracing(FRAMEWORK, telemetry, server_url)
100
+ _native.attach_processor(
101
+ _native.global_provider(provider, FRAMEWORK),
102
+ telemetry,
103
+ framework=FRAMEWORK,
104
+ scopes=_SCOPES,
105
+ default_agent_name="ADK Agent",
106
+ native_conversation_id=True,
107
+ )
108
+ # ADK reads the variable per invocation, so no refresh is needed.
109
+ _native.opt_in_tokens(FRAMEWORK, [_LATEST])
110
+
111
+ print(
112
+ f"[MONITOR] {FRAMEWORK} monitoring installed via native OpenTelemetry "
113
+ f"(server: {server_url})"
114
+ )
115
+ return telemetry
116
+
117
+
118
+ def uninstall_adk_monitoring() -> None:
119
+ """Stop stamping identity and sending events; drop the opt-in tokens it added.
120
+
121
+ ADK keeps tracing through the global provider, since that's how it always
122
+ works. Only this hook's contributions stop.
123
+ """
124
+ from opentelemetry import trace
125
+
126
+ _native.detach(FRAMEWORK, otel._provider)
127
+ _native.detach(FRAMEWORK, trace.get_tracer_provider())
128
+ _native.release_opt_in(FRAMEWORK)
otelzero/hooks/base.py ADDED
@@ -0,0 +1,546 @@
1
+ """Shared import-hook machinery and telemetry helpers for framework hooks.
2
+
3
+ This module holds everything the per-framework hooks (``crewai``, ``langchain``,
4
+ ``strands``, ``adk``, ``databricks``) build on:
5
+
6
+ * :class:`_ImportInstrumenter` / :class:`_InstrumentingLoader` -- the
7
+ ``sys.meta_path`` plumbing that instruments a watched module right after it is
8
+ imported. Subclasses only declare *which* modules to watch and *how* to wrap
9
+ them.
10
+ * :func:`trace_agent_run` -- the span + start/end/error telemetry pattern every
11
+ ``_wrap_*`` method shares, factored into one context manager.
12
+ * :func:`start_agent_run` / :class:`AgentRun` -- the same pattern for a framework
13
+ whose run entry point returns *before* the run finishes, so the terminal event
14
+ is emitted from wherever the run actually ends.
15
+ * :func:`install_hook` -- the ``TelemetryClient`` + register boilerplate every
16
+ ``install_*_monitoring`` entry point shares.
17
+ * :func:`nesting_guard` -- the re-entrancy guard hooks need when a framework's
18
+ public entry points call each other, or nest a run inside a run.
19
+ """
20
+
21
+ import contextvars
22
+ import importlib.abc
23
+ import importlib.util
24
+ import sys
25
+ import traceback as tb
26
+ from contextlib import contextmanager
27
+ from typing import Dict, Optional
28
+
29
+ from otelzero import otel, semconv
30
+ from otelzero.hooks import _content
31
+ from otelzero.telemetry import TelemetryClient
32
+
33
+
34
+ class _InstrumentingLoader(importlib.abc.Loader):
35
+ """Defer to the real loader, then instrument the module once it executes."""
36
+
37
+ def __init__(self, loader, hook, fullname):
38
+ self._loader = loader
39
+ self._hook = hook
40
+ self._fullname = fullname
41
+
42
+ def create_module(self, spec):
43
+ return self._loader.create_module(spec)
44
+
45
+ def exec_module(self, module):
46
+ self._loader.exec_module(module)
47
+ self._hook._instrument_once(self._fullname, module)
48
+
49
+
50
+ class _ImportInstrumenter(importlib.abc.MetaPathFinder):
51
+ """Base meta-path finder that instruments watched modules after import.
52
+
53
+ Uses the modern ``find_spec`` protocol (Python >= 3.12; the legacy
54
+ ``find_module`` protocol was removed in 3.12). It resolves the *real* spec
55
+ from the rest of ``sys.meta_path`` -- guarding against re-entrancy -- and wraps
56
+ its loader so the module is instrumented immediately after it executes.
57
+
58
+ Subclasses set :attr:`WATCHED`, a mapping of watched module name to the name
59
+ of the ``_wrap_*`` method that instruments it.
60
+ """
61
+
62
+ # module fullname -> name of the instance method that instruments it.
63
+ WATCHED: Dict[str, str] = {}
64
+
65
+ def __init__(self, telemetry_client: "TelemetryClient"):
66
+ self.telemetry = telemetry_client
67
+ self.instrumented_modules = set()
68
+ self._finding = set()
69
+
70
+ def _watches(self, fullname: str) -> bool:
71
+ return fullname in self.WATCHED
72
+
73
+ def _instrument_module(self, fullname, module):
74
+ getattr(self, self.WATCHED[fullname])(module)
75
+
76
+ def find_spec(self, fullname, path=None, target=None):
77
+ if not self._watches(fullname) or fullname in self._finding:
78
+ return None
79
+ # Re-enter the import machinery to find the real spec, guarding this
80
+ # name so our own finder is skipped on the recursive lookup.
81
+ self._finding.add(fullname)
82
+ try:
83
+ spec = importlib.util.find_spec(fullname)
84
+ except ImportError:
85
+ return None
86
+ finally:
87
+ self._finding.discard(fullname)
88
+ if spec is None or spec.loader is None:
89
+ return None
90
+ spec.loader = _InstrumentingLoader(spec.loader, self, fullname)
91
+ return spec
92
+
93
+ def _instrument_once(self, fullname, module):
94
+ if fullname not in self.instrumented_modules:
95
+ self._instrument_module(fullname, module)
96
+ self.instrumented_modules.add(fullname)
97
+ print(f"[INSTRUMENT] Instrumented {fullname}")
98
+
99
+
100
+ class RunContext:
101
+ """Mutable handle a wrapper uses to attach data discovered during the run.
102
+
103
+ ``trace_agent_run`` yields one of these; a hook that finds token usage on
104
+ the framework's response (or accumulates it across a stream) calls
105
+ :meth:`set_usage` so the usage lands on the terminal telemetry event and
106
+ the OTel span. :meth:`set_model` does the same for the model id when the
107
+ framework only names it in the response.
108
+
109
+ :meth:`set_input` / :meth:`set_output` / :meth:`set_system` do the same for
110
+ the run's prompt, response and system prompt, which land as the Opt-In
111
+ ``gen_ai.input.messages`` / ``gen_ai.output.messages`` /
112
+ ``gen_ai.system_instructions`` span attributes and as ``input_messages`` /
113
+ ``output_messages`` / ``system_instructions`` in the terminal event's
114
+ ``details``. All three are no-ops unless the user opted in to content
115
+ capture (see :mod:`otelzero.hooks._content`).
116
+ """
117
+
118
+ __slots__ = (
119
+ "span",
120
+ "usage",
121
+ "model",
122
+ "input_messages",
123
+ "output_messages",
124
+ "system_instructions",
125
+ "finish_reasons",
126
+ )
127
+
128
+ def __init__(self, span):
129
+ self.span = span
130
+ self.usage: Optional[dict] = None
131
+ self.model: Optional[str] = None
132
+ self.input_messages: Optional[list] = None
133
+ self.output_messages: Optional[list] = None
134
+ self.system_instructions: Optional[list] = None
135
+ self.finish_reasons: Optional[list] = None
136
+
137
+ def set_usage(self, usage: Optional[dict]) -> None:
138
+ if usage:
139
+ self.usage = usage
140
+
141
+ def set_model(self, model: Optional[str]) -> None:
142
+ """Name the model a run turned out to use.
143
+
144
+ For hooks that cannot read the model off the agent before the run: a
145
+ LangGraph node closes over its chat model, so the graph exposes nothing
146
+ to read, but every reply message says which model produced it. The first
147
+ model reported wins, so one run mixing models is labelled by its first.
148
+ """
149
+ if model and not self.model:
150
+ self.model = model
151
+
152
+ def set_input(self, messages) -> None:
153
+ """Record the prompt: a shaped message list or plain text."""
154
+ messages = _content.as_messages(messages, "user")
155
+ if messages:
156
+ self.input_messages = messages
157
+
158
+ def set_output(self, messages, finish_reasons=None) -> None:
159
+ """Record the response: a shaped message list or plain text.
160
+
161
+ ``finish_reasons`` -- one normalised reason (see
162
+ ``_content.finish_reason``) or a list of them, one per generation the
163
+ framework reported -- becomes ``gen_ai.response.finish_reasons``, where
164
+ the conventions want it; the field an output message used to carry is
165
+ deprecated. It is recorded whether or not content capture is opted in,
166
+ and left unset when the framework named no reason.
167
+ """
168
+ if isinstance(finish_reasons, str):
169
+ finish_reasons = [finish_reasons]
170
+ if finish_reasons:
171
+ self.finish_reasons = [r for r in finish_reasons if r]
172
+ messages = _content.as_messages(messages, "assistant")
173
+ if messages:
174
+ self.output_messages = list(messages)
175
+
176
+ def set_system(self, parts) -> None:
177
+ """Record the system prompt: a shaped part list or plain text."""
178
+ parts = _content.as_parts(parts)
179
+ if parts:
180
+ self.system_instructions = parts
181
+
182
+
183
+ def _with_identity(telemetry: "TelemetryClient", attributes: dict) -> dict:
184
+ """Stamp a span's attributes with who is running it and which flow it is.
185
+
186
+ Every hook opens its span through :func:`trace_agent_run`, so doing this in
187
+ one place keeps ``gen_ai.conversation.id`` and ``user.name`` on every otelzero
188
+ span across all frameworks, plus ``otelzero.session.name`` once the session
189
+ has a title. They are span-level rather than resource-level because one
190
+ process can serve many users and many concurrent flows.
191
+ """
192
+ user, session, session_name = telemetry.run_identity()
193
+ attrs = dict(attributes or {})
194
+ if session:
195
+ attrs[semconv.GEN_AI_CONVERSATION_ID] = session
196
+ if session_name:
197
+ attrs[semconv.OTELZERO_SESSION_NAME] = session_name
198
+ if user:
199
+ attrs[semconv.USER_NAME] = user
200
+ return attrs
201
+
202
+
203
+ def _merge_run_details(details: Optional[dict], ctx: RunContext) -> Optional[dict]:
204
+ """The terminal event's ``details``: the hook's own plus what the run found.
205
+
206
+ Token usage and the run's finish reasons always ride along; message content
207
+ only when the user opted in -- the same switch that gates the span
208
+ attributes, so the HTTP and OTLP paths never disagree about what was
209
+ recorded.
210
+ """
211
+ extra = {}
212
+ if ctx.usage:
213
+ extra["usage"] = ctx.usage
214
+ if ctx.finish_reasons:
215
+ extra["finish_reasons"] = list(ctx.finish_reasons)
216
+ if semconv.capture_content_enabled():
217
+ for key in ("input_messages", "output_messages", "system_instructions"):
218
+ value = getattr(ctx, key)
219
+ if value:
220
+ extra[key] = value
221
+ if not extra:
222
+ return details
223
+ return {**(details or {}), **extra}
224
+
225
+
226
+ def _close_span(span, ctx: RunContext) -> None:
227
+ """Stamp everything the run discovered onto its span before it ends."""
228
+ otel.set_usage(span, ctx.usage)
229
+ otel.set_finish_reasons(span, semconv.finish_reasons(ctx.finish_reasons))
230
+ otel.set_messages(
231
+ span,
232
+ semconv.input_messages(ctx.input_messages),
233
+ semconv.output_messages(ctx.output_messages),
234
+ semconv.system_instructions(ctx.system_instructions),
235
+ )
236
+
237
+
238
+ def _with_model(fields: dict, ctx: "RunContext") -> dict:
239
+ """Add a late-discovered model to a terminal event's log fields.
240
+
241
+ A model the hook already knew at ``*_start`` wins: it is what the run asked
242
+ for, whereas this one is what the response happened to report.
243
+ """
244
+ if not ctx.model or fields.get("model"):
245
+ return fields
246
+ return {**fields, "model": ctx.model}
247
+
248
+
249
+ def _close_run(
250
+ telemetry: "TelemetryClient",
251
+ ctx: "RunContext",
252
+ *,
253
+ end_event: str,
254
+ error_event: str,
255
+ fields: dict,
256
+ end_details: Optional[dict],
257
+ exc: Optional[BaseException] = None,
258
+ interrupted: bool = False,
259
+ ) -> None:
260
+ """Emit a run's terminal telemetry and close out its span attributes.
261
+
262
+ The single place a run ends, whichever form started it: the ``with`` block
263
+ below, or the deferred :class:`AgentRun`. Usage and model land on the span
264
+ first (attributes set after ``end()`` are silently dropped), then the
265
+ terminal log goes out, and only then is the error recorded on the span --
266
+ the order the hooks' payload tests pin.
267
+
268
+ ``exc`` marks a failed run, ``interrupted`` a streaming consumer that
269
+ stopped early or a cancelled run; a caller-supplied ``end_details`` still
270
+ wins over the interrupted status, because a hook that named its own terminal
271
+ details (CrewAI's ``completed``) means them either way.
272
+ """
273
+ _close_span(ctx.span, ctx) # partial usage/content on failed runs
274
+ otel.set_model(ctx.span, ctx.model)
275
+ if exc is not None:
276
+ telemetry.send_log(
277
+ event_type=error_event,
278
+ error=str(exc),
279
+ traceback="".join(tb.format_exception(exc)),
280
+ details=_merge_run_details(None, ctx),
281
+ **_with_model(fields, ctx),
282
+ )
283
+ otel.record_error(ctx.span, exc)
284
+ return
285
+ default = {"status": "interrupted"} if interrupted else {"status": "success"}
286
+ telemetry.send_log(
287
+ event_type=end_event,
288
+ details=_merge_run_details(
289
+ end_details if end_details is not None else default, ctx
290
+ ),
291
+ **_with_model(fields, ctx),
292
+ )
293
+
294
+
295
+ @contextmanager
296
+ def trace_agent_run(
297
+ telemetry: "TelemetryClient",
298
+ span_name: str,
299
+ span_attributes: dict,
300
+ *,
301
+ start_event: str,
302
+ end_event: str,
303
+ error_event: str,
304
+ log_fields: Optional[dict] = None,
305
+ start_details: Optional[dict] = None,
306
+ end_details: Optional[dict] = None,
307
+ session_name_hint: Optional[str] = None,
308
+ ):
309
+ """Open an otelzero span and emit start/success/error telemetry around a run.
310
+
311
+ Wraps the boilerplate shared by every framework hook: open an OTel span,
312
+ send a ``*_start`` log, then -- around the wrapped call running in the
313
+ ``with`` body -- send ``*_end`` on success or ``*_error`` and re-raise on
314
+ failure (recording the error on the span). A successful run leaves the span
315
+ status untouched, which the conventions require.
316
+
317
+ Yields a :class:`RunContext`; token usage attached via ``set_usage`` is
318
+ merged into the terminal log's ``details["usage"]`` and set as
319
+ ``gen_ai.usage.*`` span attributes, and a model named via ``set_model``
320
+ fills in ``model`` on the terminal event and the span when the hook could
321
+ not read one before the run. Content attached via ``set_input`` /
322
+ ``set_output`` / ``set_system`` lands the same way -- on the terminal log
323
+ (success, error or interrupted alike) and as the Opt-In ``gen_ai.*``
324
+ content attributes -- but only when the user opted in to content capture.
325
+
326
+ ``log_fields`` (e.g. ``agent_name``/``agent_role``/``model``) are attached
327
+ to every log this emits; ``start_details``/``end_details`` populate the start/end logs'
328
+ ``details`` (``end_details`` defaults to ``{"status": "success"}``).
329
+ ``session_name_hint`` -- the run's intent text (a task description, agent
330
+ goal, or prompt) -- names the session before the start event is sent, so the
331
+ dashboard and apps show a meaningful title; the first non-empty hint across a
332
+ session's runs wins (see :meth:`TelemetryClient.suggest_session_name`). Works for
333
+ sync, ``async``, and async-generator wrapped calls alike, since the wrapped
334
+ call lives in the caller's ``with`` body. When a streaming consumer stops
335
+ early (``GeneratorExit`` in an async-generator wrapper), the ``*_end``
336
+ event is still emitted -- with ``{"status": "interrupted"}`` unless the hook
337
+ provided ``end_details`` -- so interrupted runs don't dangle open.
338
+ """
339
+ fields = log_fields or {}
340
+ telemetry.suggest_session_name(session_name_hint)
341
+ with otel.span(span_name, _with_identity(telemetry, span_attributes)) as span:
342
+ ctx = RunContext(span)
343
+ telemetry.send_log(event_type=start_event, details=start_details, **fields)
344
+
345
+ def close(**outcome):
346
+ _close_run(
347
+ telemetry,
348
+ ctx,
349
+ end_event=end_event,
350
+ error_event=error_event,
351
+ fields=fields,
352
+ end_details=end_details,
353
+ **outcome,
354
+ )
355
+
356
+ try:
357
+ yield ctx
358
+ except GeneratorExit:
359
+ # Streaming consumer stopped early (break/aclose): close the run
360
+ # with whatever usage accumulated, then keep unwinding.
361
+ close(interrupted=True)
362
+ raise
363
+ except Exception as e:
364
+ close(exc=e)
365
+ raise
366
+ close()
367
+
368
+
369
+ class AgentRun:
370
+ """A run that is started now and finished later, from wherever it ends.
371
+
372
+ :func:`trace_agent_run`'s ``with`` block assumes the wrapped call *is* the
373
+ run. Some frameworks break that assumption: LlamaIndex's ``Workflow.run`` is
374
+ synchronous, schedules an asyncio task and returns a handler immediately, so
375
+ a block around it would close the span before the run started. Such a hook
376
+ opens the run with :func:`start_agent_run`, holds :meth:`activate` around the
377
+ call that schedules the task -- so the task inherits the span as its parent --
378
+ and calls :meth:`finish` from the task's done-callback.
379
+
380
+ :meth:`finish` is idempotent: a done-callback and a teardown path may both
381
+ reach for a run that is already closed, and reporting it twice would show as
382
+ two overlapping runs of one agent.
383
+ """
384
+
385
+ __slots__ = ("ctx", "_telemetry", "_events", "_fields", "_end_details", "_closed")
386
+
387
+ def __init__(self, telemetry, span, *, events, fields, end_details):
388
+ self.ctx = RunContext(span)
389
+ self._telemetry = telemetry
390
+ self._events = events # (end_event, error_event)
391
+ self._fields = fields
392
+ self._end_details = end_details
393
+ self._closed = False
394
+
395
+ @property
396
+ def span(self):
397
+ return self.ctx.span
398
+
399
+ def set_usage(self, usage: Optional[dict]) -> None:
400
+ self.ctx.set_usage(usage)
401
+
402
+ def set_model(self, model: Optional[str]) -> None:
403
+ self.ctx.set_model(model)
404
+
405
+ def set_input(self, messages) -> None:
406
+ self.ctx.set_input(messages)
407
+
408
+ def set_output(self, messages, finish_reasons=None) -> None:
409
+ self.ctx.set_output(messages, finish_reasons)
410
+
411
+ def set_system(self, parts) -> None:
412
+ self.ctx.set_system(parts)
413
+
414
+ @contextmanager
415
+ def activate(self):
416
+ """Make the run's span current for the block, without ending it."""
417
+ with otel.use_span(self.ctx.span):
418
+ yield self
419
+
420
+ def finish(
421
+ self, exc: Optional[BaseException] = None, interrupted: bool = False
422
+ ) -> None:
423
+ """Close the run exactly once: terminal event, then end the span."""
424
+ if self._closed:
425
+ return
426
+ self._closed = True
427
+ end_event, error_event = self._events
428
+ try:
429
+ _close_run(
430
+ self._telemetry,
431
+ self.ctx,
432
+ end_event=end_event,
433
+ error_event=error_event,
434
+ fields=self._fields,
435
+ end_details=self._end_details,
436
+ exc=exc,
437
+ interrupted=interrupted,
438
+ )
439
+ finally:
440
+ otel.end_span(self.ctx.span)
441
+
442
+
443
+ def start_agent_run(
444
+ telemetry: "TelemetryClient",
445
+ span_name: str,
446
+ span_attributes: dict,
447
+ *,
448
+ start_event: str,
449
+ end_event: str,
450
+ error_event: str,
451
+ log_fields: Optional[dict] = None,
452
+ start_details: Optional[dict] = None,
453
+ end_details: Optional[dict] = None,
454
+ session_name_hint: Optional[str] = None,
455
+ ) -> AgentRun:
456
+ """Open a run whose end is reported later -- the deferred :func:`trace_agent_run`.
457
+
458
+ Takes the same arguments and emits the same payloads; the difference is that
459
+ the span is started *without* being made current and the terminal event is
460
+ the caller's job, via the returned :class:`AgentRun`. For a framework whose
461
+ run entry point returns a handle rather than a result -- see
462
+ :mod:`otelzero.hooks.llama_index`.
463
+ """
464
+ fields = log_fields or {}
465
+ telemetry.suggest_session_name(session_name_hint)
466
+ span = otel.start_span(span_name, _with_identity(telemetry, span_attributes))
467
+ run = AgentRun(
468
+ telemetry,
469
+ span,
470
+ events=(end_event, error_event),
471
+ fields=fields,
472
+ end_details=end_details,
473
+ )
474
+ telemetry.send_log(event_type=start_event, details=start_details, **fields)
475
+ return run
476
+
477
+
478
+ def nesting_guard(name: str):
479
+ """Build a re-entrancy guard for a framework whose run entry points nest.
480
+
481
+ Some frameworks offer several public ways to start a run that are really one
482
+ run underneath: LangGraph's ``invoke`` drains its own ``stream``, and a graph
483
+ node holding a subgraph re-enters the runtime a level down; the OpenAI Agents
484
+ SDK's ``run_sync`` drives ``run``. Wrapping each entry point is still right --
485
+ callers use all of them -- but only the outermost call should open a span, or
486
+ one agent run would be reported as two overlapping ones.
487
+
488
+ Returns a context-manager factory yielding ``True`` for the outermost call on
489
+ the current context and ``False`` for anything nested inside it. The depth
490
+ lives in a :class:`~contextvars.ContextVar`, so concurrent ``asyncio`` tasks
491
+ and threads each track their own nesting rather than masking each other's
492
+ runs. Frameworks that hand node execution to a worker thread *without*
493
+ copying the context will not nest across that boundary; the guard degrades to
494
+ reporting the inner run separately, never to losing one.
495
+ """
496
+ var: contextvars.ContextVar = contextvars.ContextVar(
497
+ f"otelzero_depth_{name}", default=0
498
+ )
499
+
500
+ @contextmanager
501
+ def guard():
502
+ if var.get():
503
+ yield False
504
+ return
505
+ token = var.set(1)
506
+ try:
507
+ yield True
508
+ finally:
509
+ var.reset(token)
510
+
511
+ return guard
512
+
513
+
514
+ def install_hook(
515
+ hook_cls,
516
+ *,
517
+ framework: str,
518
+ label: str,
519
+ server_url: str,
520
+ user: Optional[str],
521
+ api_key: Optional[str] = None,
522
+ session: Optional[str] = None,
523
+ name: Optional[str] = None,
524
+ ) -> TelemetryClient:
525
+ """Wire up a framework hook: build the client and register it.
526
+
527
+ Creates a :class:`TelemetryClient` for ``framework``, instantiates
528
+ ``hook_cls`` and inserts it at the front of ``sys.meta_path`` so subsequent
529
+ imports of the framework are instrumented. ``label`` is the human-readable
530
+ name used in the ``[MONITOR]`` console output. ``api_key`` ties events to an
531
+ owner (for the iOS app); ``session`` groups one run's events; ``name`` is an
532
+ explicit session title (else it is derived from the run's intent). Returns
533
+ the telemetry client.
534
+ """
535
+ telemetry = TelemetryClient(
536
+ server_url,
537
+ framework=framework,
538
+ user=user,
539
+ api_key=api_key,
540
+ session=session,
541
+ name=name,
542
+ )
543
+ hook = hook_cls(telemetry)
544
+ sys.meta_path.insert(0, hook)
545
+ print(f"[MONITOR] {label} monitoring installed (server: {server_url})")
546
+ return telemetry