darkhunt-telemetry 0.5.13__tar.gz

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.
Files changed (47) hide show
  1. darkhunt_telemetry-0.5.13/.claude/skills/darkhunt-telemetry-integration/SKILL.md +621 -0
  2. darkhunt_telemetry-0.5.13/.github/workflows/ci.yml +129 -0
  3. darkhunt_telemetry-0.5.13/.gitignore +15 -0
  4. darkhunt_telemetry-0.5.13/LICENSE +201 -0
  5. darkhunt_telemetry-0.5.13/NOTICE +19 -0
  6. darkhunt_telemetry-0.5.13/PKG-INFO +350 -0
  7. darkhunt_telemetry-0.5.13/README.md +309 -0
  8. darkhunt_telemetry-0.5.13/darkhunt_telemetry/__init__.py +81 -0
  9. darkhunt_telemetry-0.5.13/darkhunt_telemetry/_version.py +13 -0
  10. darkhunt_telemetry-0.5.13/darkhunt_telemetry/attributes.py +62 -0
  11. darkhunt_telemetry-0.5.13/darkhunt_telemetry/client.py +335 -0
  12. darkhunt_telemetry-0.5.13/darkhunt_telemetry/exporter.py +203 -0
  13. darkhunt_telemetry-0.5.13/darkhunt_telemetry/masking/__init__.py +14 -0
  14. darkhunt_telemetry-0.5.13/darkhunt_telemetry/masking/rules/rules.json +688 -0
  15. darkhunt_telemetry-0.5.13/darkhunt_telemetry/masking/sanitizer.py +261 -0
  16. darkhunt_telemetry-0.5.13/darkhunt_telemetry/masking/validators/__init__.py +42 -0
  17. darkhunt_telemetry-0.5.13/darkhunt_telemetry/masking/validators/aba.py +24 -0
  18. darkhunt_telemetry-0.5.13/darkhunt_telemetry/masking/validators/base58check.py +46 -0
  19. darkhunt_telemetry-0.5.13/darkhunt_telemetry/masking/validators/bech32.py +62 -0
  20. darkhunt_telemetry-0.5.13/darkhunt_telemetry/masking/validators/credit_card.py +85 -0
  21. darkhunt_telemetry-0.5.13/darkhunt_telemetry/masking/validators/eip55.py +52 -0
  22. darkhunt_telemetry-0.5.13/darkhunt_telemetry/masking/validators/iban_mod97.py +36 -0
  23. darkhunt_telemetry-0.5.13/darkhunt_telemetry/masking/validators/keccak.py +128 -0
  24. darkhunt_telemetry-0.5.13/darkhunt_telemetry/masking/validators/luhn.py +27 -0
  25. darkhunt_telemetry-0.5.13/darkhunt_telemetry/otel_globals.py +62 -0
  26. darkhunt_telemetry-0.5.13/darkhunt_telemetry/py.typed +0 -0
  27. darkhunt_telemetry-0.5.13/darkhunt_telemetry/span.py +664 -0
  28. darkhunt_telemetry-0.5.13/darkhunt_telemetry/temporal/__init__.py +25 -0
  29. darkhunt_telemetry-0.5.13/darkhunt_telemetry/temporal/handoff_header.py +60 -0
  30. darkhunt_telemetry-0.5.13/darkhunt_telemetry/temporal/interceptors.py +150 -0
  31. darkhunt_telemetry-0.5.13/darkhunt_telemetry/trace.py +277 -0
  32. darkhunt_telemetry-0.5.13/darkhunt_telemetry/transports/__init__.py +29 -0
  33. darkhunt_telemetry-0.5.13/darkhunt_telemetry/transports/http.py +65 -0
  34. darkhunt_telemetry-0.5.13/darkhunt_telemetry/transports/queue.py +87 -0
  35. darkhunt_telemetry-0.5.13/darkhunt_telemetry/types.py +78 -0
  36. darkhunt_telemetry-0.5.13/pyproject.toml +114 -0
  37. darkhunt_telemetry-0.5.13/sonar-project.properties +38 -0
  38. darkhunt_telemetry-0.5.13/tests/conftest.py +34 -0
  39. darkhunt_telemetry-0.5.13/tests/test_exporter.py +112 -0
  40. darkhunt_telemetry-0.5.13/tests/test_handoff.py +81 -0
  41. darkhunt_telemetry-0.5.13/tests/test_keccak.py +87 -0
  42. darkhunt_telemetry-0.5.13/tests/test_masking.py +115 -0
  43. darkhunt_telemetry-0.5.13/tests/test_masking_parity.py +67 -0
  44. darkhunt_telemetry-0.5.13/tests/test_temporal.py +45 -0
  45. darkhunt_telemetry-0.5.13/tests/test_tracing.py +187 -0
  46. darkhunt_telemetry-0.5.13/tests/test_transports.py +58 -0
  47. darkhunt_telemetry-0.5.13/uv.lock +1358 -0
@@ -0,0 +1,621 @@
1
+ ---
2
+ name: darkhunt-telemetry-integration
3
+ description: |
4
+ Use this skill when integrating `darkhunt-telemetry` (the Darkhunt trace-hub
5
+ PYTHON SDK at /Users/sergey/proj/darkhunt/darkhunt-telemetry-python) into a
6
+ Python service. Covers: install (it is NOT on PyPI), singleton client setup,
7
+ trace + generation + span emission via the `with`-based active-context helpers,
8
+ backdated `start_time`, graceful shutdown, routing-field discipline (tenant_id /
9
+ workspace_id / application_id), creating an OBSERVABILITY application via the
10
+ Darkhunt MCP, in-cluster vs public ingest, the masking layer, and — the big part
11
+ — multi-agent topology + agent handoffs across every Python transport (an
12
+ in-process contextvars carrier, orchestrator-passed traces, a LangGraph state
13
+ field, the HTTP `traceparent` header, a queue metadata field, and Temporal
14
+ Headers via `HandoffInterceptor` + `child_args`). Auto-invoke when the user asks
15
+ to add LLM tracing/observability to a Python app, send spans to trace-hub, wire
16
+ `DarkhuntTelemetry` / `client.trace()` / `trace.generation()`, or build a
17
+ multi-agent Python system where agents hand off to each other (agent topology).
18
+ ---
19
+
20
+ # Darkhunt telemetry Python SDK — integration guide
21
+
22
+ This skill walks through wiring `darkhunt-telemetry` (the **Python** SDK) into a
23
+ Python service. It is the Python analog of the TypeScript
24
+ `darkhunt-telemetry-integration` skill — same wire contract, same routing
25
+ semantics, same masking ruleset, adapted to Python idioms (keyword arguments,
26
+ `with` context managers, `contextvars`).
27
+
28
+ **The reference integration is `temporal-demo-python`** at
29
+ `/Users/sergey/proj/darkhunt/temporal-demo-python` — a six-domain multi-agent demo
30
+ where each domain uses a *different* orchestration style, so it demonstrates every
31
+ handoff transport at once. Read it when in doubt; the patterns below are extracted
32
+ from it. The SDK source lives at
33
+ `/Users/sergey/proj/darkhunt/darkhunt-telemetry-python` and ships a `README.md`
34
+ with the full API reference + masking docs — **read that README** for anything the
35
+ patterns below don't cover.
36
+
37
+ ## What the SDK is
38
+
39
+ A Darkhunt-specific span exporter built on OpenTelemetry primitives
40
+ (`TracerProvider`, `BatchSpanProcessor`, OTLP/protobuf) that ships spans — traces,
41
+ LLM generations, tool calls, retrievals, guardrails — to Darkhunt trace-hub.
42
+ Routing semantics (`tenant_id` / `workspace_id` / `application_id`) and the
43
+ attribute schema are Darkhunt-specific; trace-hub is the only intended receiver.
44
+ Built-in client-side masking redacts 66 secret/PII patterns before payloads leave
45
+ the process. Requires Python **3.9+**.
46
+
47
+ Key shapes:
48
+
49
+ - **`DarkhuntTelemetry`** — the client. One per process, lifetime-of-the-process.
50
+ - **`Trace`** — a single user-facing interaction. Carries routing fields.
51
+ - **`Generation`** — one LLM round-trip under a trace (`model`, messages, `usage`, `cost`).
52
+ - **`Span`** — anything else (tool calls, retrievals, guardrails, sub-agents). Use
53
+ `observation_type` to categorize.
54
+
55
+ ## Step-by-step integration
56
+
57
+ ### 1. Install — the SDK is NOT on PyPI
58
+
59
+ `pip install darkhunt-telemetry` from public PyPI **fails** (404). Depend on it one
60
+ of these ways:
61
+
62
+ - **Local path** (simplest for a repo that sits beside the SDK checkout). With uv:
63
+
64
+ ```toml
65
+ # pyproject.toml
66
+ dependencies = ["darkhunt-telemetry[temporal]>=0.5.5"]
67
+
68
+ [tool.uv.sources]
69
+ darkhunt-telemetry = { path = "../darkhunt-telemetry-python", editable = true }
70
+ ```
71
+
72
+ then `uv sync`. Plain pip: `pip install -e "../darkhunt-telemetry-python[temporal]"`.
73
+ - **Git dependency** (works from any clone, incl. CI/Docker without a local
74
+ checkout): `darkhunt-telemetry @ git+https://github.com/darkhunt-security/darkhunt-telemetry-python@<ref>`.
75
+ - **Private index**, if your org publishes one.
76
+
77
+ **Extras:** `[temporal]` pulls in `temporalio` (only needed for the Temporal
78
+ handoff interceptors); `[crypto]` adds the vetted Keccak validator. The core
79
+ package imports neither, so it loads with zero Temporal/crypto packages installed.
80
+
81
+ **Docker + a local PATH dependency (the trap).** A `Dockerfile` with `context: .`
82
+ can't `COPY` a sibling path dep that lives outside the build context, and widening
83
+ the context to the parent dir sends the whole monorepo. Use a **named additional
84
+ build context** instead (Buildx / Compose):
85
+
86
+ ```yaml
87
+ # docker-compose.yml
88
+ build:
89
+ context: .
90
+ additional_contexts:
91
+ dhsdk: ../darkhunt-telemetry-python
92
+ ```
93
+ ```dockerfile
94
+ # Place the SDK as a SIBLING of the project dir so the ../ path resolves.
95
+ WORKDIR /app/darkhunt-telemetry-python
96
+ COPY --from=dhsdk pyproject.toml README.md LICENSE NOTICE ./
97
+ COPY --from=dhsdk darkhunt_telemetry ./darkhunt_telemetry
98
+ WORKDIR /app/temporal-demo-python
99
+ COPY pyproject.toml uv.lock ./
100
+ COPY src ./src
101
+ RUN uv sync --frozen --no-dev
102
+ ```
103
+
104
+ ### 2. Get an API key
105
+
106
+ A `dh-...` API key is required for public/external ingest (`internal=False` —
107
+ app servers / CLIs / workers calling the public endpoint). In-cluster
108
+ service-to-service callers using `internal=True` don't need one.
109
+
110
+ Create one in the dashboard (**app.darkhunt.ai → Settings → Security → API Keys →
111
+ + Create API key**), copy it immediately (shown once), and put it in the
112
+ **`DARKHUNT_API_KEY`** env var — the name the SDK reads by default
113
+ (`options api_key ?? DARKHUNT_API_KEY`). If it's missing on the public endpoint the
114
+ constructor raises `ValueError: api_key is required for the public endpoint`.
115
+
116
+ > **The key, the base URL, and the tenant must all be the same environment.** A
117
+ > `dh-` key is scoped to one environment's tenant. Source `DARKHUNT_API_KEY`,
118
+ > `DARKHUNT_BASE_URL`, and `DARKHUNT_TENANT_ID` from the **same** place — enrolling
119
+ > the `darkhunt-cli` writes a matched trio to `~/.darkhunt/credentials.json`
120
+ > (`{ apiKey, apiBaseUrl, tenantId }`); prefer that set together.
121
+
122
+ > **Gotcha: `credentials.json apiBaseUrl` has NO `/trace-hub` suffix** (it stores
123
+ > the bare host, e.g. `https://api.darkhunt.ai`). The SDK default `base_url` DOES
124
+ > include it (`https://api.darkhunt.ai/trace-hub`). If you source `DARKHUNT_BASE_URL`
125
+ > from that field, **append `/trace-hub` yourself** — the exporter posts to
126
+ > `{base_url}/otlp/t/{tenant_id}/v1/traces`, so dropping it yields 404.
127
+
128
+ ### 2b. Create an OBSERVABILITY application (get the `application_id`)
129
+
130
+ Every trace needs an `application_id` (a workspace-scoped UUID). **Create a new,
131
+ dedicated OBSERVABILITY app** for the integration — do not reuse a random existing
132
+ app (its traces would pollute that scope). In a multi-agent system, **one app per
133
+ domain / service group** is typical (agents within a domain are told apart by
134
+ `service.name`).
135
+
136
+ **Reach for the Darkhunt MCP first** (it reuses enrolled credentials). Check the
137
+ `darkhunt_*` tools are connected, then:
138
+
139
+ ```text
140
+ darkhunt_status # confirm auth + tenant + reachable API
141
+ darkhunt_list_workspaces # → pick your workspaceId (UUID)
142
+ darkhunt_create_application { workspace, name, type: 'OBSERVABILITY', description? }
143
+ # → returns the NEW application's UUID (use this)
144
+ ```
145
+
146
+ - Pass **`type: 'OBSERVABILITY'`** (default is `RED_TEAM`) so the app's **Tracing**
147
+ view is enabled.
148
+ - Put each UUID in `DARKHUNT_APP_<DOMAIN>` (or `DARKHUNT_APPLICATION_ID` for a
149
+ single-app service). If MCP + CLI are both unavailable, use the dashboard's
150
+ new-application flow; don't silently curl the REST API.
151
+
152
+ ### 3. Singleton client (process-wide)
153
+
154
+ **Don't construct `DarkhuntTelemetry` per request.** Each construction spins up a
155
+ `TracerProvider` + `BatchSpanProcessor` and registers an `atexit` handler, so a
156
+ per-call client leaks resources and prevents batching. `service.name` is a
157
+ **per-client** OTel resource, so distinct agent names need distinct clients — in a
158
+ multi-agent process, **memoize one client per `(application_id, service_name)`**:
159
+
160
+ ```python
161
+ from darkhunt_telemetry import DarkhuntTelemetry
162
+
163
+ _clients: dict[str, DarkhuntTelemetry] = {}
164
+
165
+ def client_for(service_name: str, application_id: str) -> DarkhuntTelemetry:
166
+ key = f"{application_id}::{service_name}"
167
+ c = _clients.get(key)
168
+ if c is None:
169
+ c = DarkhuntTelemetry(
170
+ # api_key / base_url / tenant / workspace read from env by default.
171
+ application_id=application_id,
172
+ service_name=service_name, # the topology node identity
173
+ internal=False, # public ingest with the dh- bearer key
174
+ )
175
+ _clients[key] = c
176
+ return c
177
+ ```
178
+
179
+ ### 4. Graceful degradation — never crash a host app without creds
180
+
181
+ The client **raises** if `api_key` is missing on the public endpoint, and
182
+ `client.trace()` **raises** if a routing field is missing. So gate on config
183
+ presence and return `None` when unconfigured; call sites use `if trace:` /
184
+ `trace.end() if trace else None`:
185
+
186
+ ```python
187
+ import os
188
+
189
+ _REQUIRED = ("DARKHUNT_API_KEY", "DARKHUNT_TENANT_ID", "DARKHUNT_WORKSPACE_ID")
190
+ _ENABLED = os.environ.get("DARKHUNT_ENABLED") != "false" and all(os.environ.get(k) for k in _REQUIRED)
191
+
192
+ def open_agent_trace(domain, agent, *, session_id, user_id, handoff_from=None, input=None):
193
+ if not _ENABLED:
194
+ return None
195
+ app_id = os.environ.get(f"DARKHUNT_APP_{domain.upper()}")
196
+ if not app_id:
197
+ return None
198
+ client = client_for(f"{domain}.{agent}", app_id)
199
+ tokens = [t for t in (handoff_from or []) if t]
200
+ return client.trace(name=f"{domain}.{agent}", session_id=session_id, user_id=user_id,
201
+ handoff_from=tokens or None, input=input)
202
+ ```
203
+
204
+ `temporal-demo-python/src/demo/telemetry.py` is this exact pattern, production-ready
205
+ (`open_agent_trace` / `open_gateway_trace` / `trace_chat` / `trace_gen` /
206
+ `trace_tool` / `flush_telemetry` / `shutdown_telemetry`). **Copy it as your
207
+ starting point.**
208
+
209
+ ### 5. Wire shutdown on signals
210
+
211
+ Spans batch in the background. The SDK flushes at process exit via `atexit`, but
212
+ **signal-driven shutdown (SIGTERM/SIGINT — `docker stop`, `kill`) bypasses
213
+ `atexit`**, losing the in-memory batch. Wire it:
214
+
215
+ ```python
216
+ # Long-running server:
217
+ try:
218
+ uvicorn.run(app, ...) # blocks; returns on SIGINT/SIGTERM
219
+ finally:
220
+ for c in _clients.values():
221
+ c.shutdown()
222
+
223
+ # One-shot script / per-task on a server:
224
+ client.flush() # before returning
225
+ ```
226
+
227
+ The Temporal `Worker.run()` resolves on shutdown — call `shutdown()` in its
228
+ `finally`. For a per-request worker (HTTP/queue consumer), `client.flush()` after
229
+ each handled task is cheap insurance since `atexit` won't fire until the process
230
+ ends.
231
+
232
+ ### 6. Wrap each LLM call — prefer the `with`-based active form
233
+
234
+ The Python SDK's active-context helpers are **synchronous `with` blocks**, which
235
+ fit a blocking `chat()` perfectly (and still work called from `async` code):
236
+
237
+ ```python
238
+ def trace_chat(trace, name, model, input_messages, call):
239
+ if trace is None:
240
+ return call() # no-op passthrough
241
+ with trace.start_active_generation(name, model=model) as gen:
242
+ gen.update(input_messages=input_messages) # known at start
243
+ res = call() # span is ACTIVE here → real timing
244
+ gen.end(model=model, output_messages=[res.message],
245
+ usage={"input_tokens": res.usage["input_tokens"],
246
+ "output_tokens": res.usage["output_tokens"]})
247
+ return res
248
+
249
+ def trace_tool(trace, tool_name, input, run):
250
+ if trace is None:
251
+ return run()
252
+ with trace.start_active_span(tool_name, observation_type="tool",
253
+ tool_name=tool_name, input=input) as span:
254
+ out = run()
255
+ span.end(output=out)
256
+ return out
257
+ ```
258
+
259
+ - `start_active_generation` / `start_active_span` **time the span automatically**,
260
+ make it the active OTel span (so provider auto-instrumentation nests under it),
261
+ end it on block exit, and mark ERROR on a raised exception. The inner `gen.end()`
262
+ / `span.end()` is idempotent, so calling it yourself to attach the payload is
263
+ fine.
264
+ - `update()` is for fields known at start (`input_messages`, `system_instructions`);
265
+ `end()` for fields known at finish (`output_messages`, `usage`, `cost`).
266
+ - **Manual form** (streaming, or holding a span open across calls): open it *after*
267
+ work started and **backdate** with `start_time` (epoch **seconds**, captured
268
+ before the call): `gen = trace.generation(name, model=..., start_time=t0)`. Epoch
269
+ seconds, not ms — the Python convention (`time.time()`).
270
+ - **You usually don't need `cost`.** trace-hub auto-prices from `model` + `usage`
271
+ for known models. Only set `cost` for custom/unpriced models.
272
+
273
+ ## Routing fields
274
+
275
+ Every span carries `tenant_id` / `workspace_id` / `application_id`. Set them once
276
+ on the client if constant for the process; pass per-trace if multi-tenant
277
+ (`client.trace(tenant_id=..., ...)`). The constructor merges
278
+ `constructor arg > env var > default`; `client.trace()` raises `ValueError` if any
279
+ is still missing. `assessment_run_id` is optional (Darkhunt-internal grouping);
280
+ omit for general production tracing.
281
+
282
+ ## `session_id` and `user_id` — set them every time
283
+
284
+ Technically optional, but **every integration should set them**. Traces sharing a
285
+ `session_id` group into one conversation timeline; the policy engine keys per-user
286
+ signals off `user_id`. If not known at open, `trace.update(user_id=..., session_id=...)`
287
+ once they are — spans created after inherit the values. Routing identifiers are
288
+ **not** masked (they round-trip verbatim for exact-match grouping) — hash any
289
+ PII-bearing identifier caller-side.
290
+
291
+ ## In-cluster vs public ingest
292
+
293
+ | Caller | `internal` | Auth | URL |
294
+ | ----------------------------- | ----------------- | --------------------------------- | ------------------------------------------------ |
295
+ | In-cluster service-to-service | `True` | none (cluster policy gates it) | `POST {base_url}/internal/t/{tenant}/v1/traces` |
296
+ | External CLI / browser / app | `False` (default) | `Authorization: Bearer <api_key>` | `POST {base_url}/otlp/t/{tenant}/v1/traces` |
297
+
298
+ ## Span types — pick the right one
299
+
300
+ | Work | Python API | `observation_type` |
301
+ | ----------------------- | -------------------------------------------------------------- | --------------------- |
302
+ | LLM round-trip | `trace.generation(name, model=...)` | (auto: `generation`) |
303
+ | External tool call | `trace.span(name, observation_type="tool", tool_name=...)` | `"tool"` |
304
+ | Vector search/retrieval | `trace.span(name, observation_type="retriever")` | `"retriever"` |
305
+ | Sub-agent step | `trace.span(name, observation_type="agent")` | `"agent"` |
306
+ | Input/output guardrail | `trace.span(name, observation_type="guardrail")` | `"guardrail"` |
307
+ | Embedding | `trace.span(name, observation_type="embedding")` | `"embedding"` |
308
+ | Generic work | `trace.span(name)` | `"span"` (default) |
309
+ | Fire-and-forget marker | `trace.event(name)` | `"event"` |
310
+
311
+ Every factory has a `start_active_*` variant. Spans nest naturally —
312
+ `parent.span(...)` / `parent.generation(...)`. For `tool` spans set `tool_name`
313
+ (and optionally `tool_call_id` / `tool_arguments`) so the dashboard shows the real
314
+ tool, not the generic type.
315
+
316
+ ## Masking (default-on)
317
+
318
+ 66 secret/PII patterns are redacted from inputs/outputs/messages/system
319
+ prompts/metadata/tool args before anything leaves the process. Add site patterns
320
+ or disable for local synthetic-data dev:
321
+
322
+ ```python
323
+ from darkhunt_telemetry import DarkhuntTelemetry, MaskingOptions
324
+ from darkhunt_telemetry.masking import CustomPattern
325
+
326
+ dh = DarkhuntTelemetry(
327
+ tenant_id="t", workspace_id="w", application_id="a",
328
+ mask=MaskingOptions(enabled=True, custom_patterns=[CustomPattern(regex=r"TICKET-\d+", marker="[TICKET]")]),
329
+ )
330
+ ```
331
+
332
+ ## Multi-agent topology & handoffs
333
+
334
+ When the service is one agent in a multi-agent system, Darkhunt reconstructs the
335
+ **agent topology** (who handed off to whom). **Identity: one `service.name` per
336
+ agent** — that string is the topology node. The graph is drawn from the
337
+ cross-service `parentSpanId` chain, so the whole job is to make each agent's root
338
+ trace **nest under its caller**.
339
+
340
+ ### The one thing to get right: nest via `handoff_from`
341
+
342
+ `client.trace(handoff_from=[caller_token])` makes the agent's root span a **child**
343
+ of `handoff_from[0]` (shared `trace_id`, `parentSpanId` set) **and** records an
344
+ `agent_handoff` link. Further entries are supplementary links (fan-in). The
345
+ caller's token is `trace.handoff_token()` (an opaque W3C `traceparent` string). The
346
+ SDK auto-registers the global OTel context manager + W3C propagator on construction,
347
+ so there is nothing else to wire — just pass `handoff_from`.
348
+
349
+ > **Why nesting matters:** ingestion **drops a contentless span** (an agent's root
350
+ > often is — its generations/tools are children) **unless** it is a cross-service
351
+ > entry (has a `parentSpanId` into another service). Nest → the root is a
352
+ > cross-service entry → kept → connected. Don't nest and don't link → the roots are
353
+ > dropped → **disconnected islands** (correct only when the agents truly never hand
354
+ > off — see the datastore/queue note below).
355
+
356
+ ### Keep the token OUT of business signatures
357
+
358
+ The token is observability plumbing — never a typed `handoff` parameter on your
359
+ domain functions, entry inputs, message/return types, or graph state visible to
360
+ business code. Carry it out-of-band, exactly like OTel carries trace context. Which
361
+ carrier depends on the topology + transport:
362
+
363
+ | Topology / transport | Carrier | Reference file (temporal-demo-python) |
364
+ | ----------------------------------- | ------------------------------------------------------------------------------------------- | --------------------------------------------- |
365
+ | **Linear in-process chain** | a `contextvars.ContextVar` (the `AsyncLocalStorage` analog) — read `current_handoff()`, publish your own | `src/demo/handoff_context.py`, `domains/weather/` |
366
+ | **Branching / fan-in in-process** | **orchestrator-driven**: the coordinator opens each agent trace with an explicit `handoff_from` and passes the `Trace` into the agent | `domains/finance/run.py` |
367
+ | **In-process graph (LangGraph)** | a dedicated field in the graph **state** (node writes its token, next node reads it as `handoff_from`) | `domains/banking/graph.py` |
368
+ | **HTTP** | the W3C `traceparent` **header** (out of the body) | `domains/healthcare/http.py` |
369
+ | **Queue (Redis/Kafka/SQS/…)** | a dedicated **message metadata field**, kept out of `data` | `domains/devops/bus.py` |
370
+ | **Temporal** | a **Temporal Header** via `HandoffInterceptor` + per-edge `child_args` | `domains/security/` |
371
+
372
+ **Linear in-process — the contextvars carrier** (a single "current" slot; perfect
373
+ for `a → b → c`, wrong for branches/fan-in):
374
+
375
+ ```python
376
+ from contextlib import contextmanager
377
+ from contextvars import ContextVar
378
+ _h: ContextVar = ContextVar("handoff", default=None)
379
+
380
+ @contextmanager
381
+ def with_handoff(token):
382
+ reset = _h.set(token)
383
+ try: yield
384
+ finally: _h.reset(reset)
385
+
386
+ def current_handoff(): return _h.get()
387
+ def publish_handoff(token): _h.set(token)
388
+
389
+ # gateway seeds the scope with its root token; each agent reads+publishes:
390
+ with with_handoff(root.handoff_token() if root else None):
391
+ plan = plan_task(inp) # opens its trace w/ handoff_from=[current_handoff()], then publish_handoff(its token)
392
+ weather = geodata(plan) # nests under coordinator; publishes its own → advisor nests under geodata
393
+ advisor(weather)
394
+ ```
395
+
396
+ **Branching / fan-in in-process — orchestrator-driven** (the single ambient slot
397
+ can't express a DAG). The coordinator opens every trace with an explicit
398
+ `handoff_from` it computes per edge, and passes a `trace` handle into each agent as
399
+ a trailing, defaults-`None` telemetry param (the bounded exception to "no tokens in
400
+ signatures"):
401
+
402
+ ```python
403
+ tok = lambda t: t.handoff_token() if t else None
404
+ root = open_gateway_trace("finance", ...); root_t = tok(root)
405
+ coord = atrace("coordinator", [root_t]); plan_task(inp, rt, coord); coord_t = tok(coord); coord.end()
406
+ research = atrace("research", [coord_t]); ...
407
+ # parallel-with-different-parents + fan-in are now trivial — each trace is explicit:
408
+ bull_th = atrace("bull", [research_t]); bear_th = atrace("bear", [research_t]) # both ← research
409
+ pm = atrace("pm", [quant_t, bull_re_t, bear_re_t]) # fan-in of 3 (handoff_from[0] = parent edge)
410
+ ```
411
+
412
+ ### Carrying the token across a transport — use the SDK helpers
413
+
414
+ ```python
415
+ from darkhunt_telemetry.transports import (
416
+ handoff_to_http_headers, handoff_from_http_headers, # HTTP: traceparent header
417
+ handoff_to_message_meta, handoff_from_message_meta, # queue: single-parent
418
+ handoffs_from_messages, # queue: fan-in (ordered, de-duped)
419
+ TRACEPARENT_HEADER, HANDOFF_MESSAGE_META_KEY,
420
+ )
421
+
422
+ # HTTP producer / consumer:
423
+ requests.post(url, headers=handoff_to_http_headers(trace.handoff_token(), base_headers))
424
+ token = handoff_from_http_headers(request.headers) # → client.trace(handoff_from=[token])
425
+
426
+ # Queue producer / consumer (token in a DEDICATED field, never inside `data`):
427
+ xadd(stream, handoff_to_message_meta(trace.handoff_token(), fields))
428
+ token = handoff_from_message_meta(parsed_fields) # single parent
429
+ tokens = handoffs_from_messages([m1, m2, m3]) # fan-in → client.trace(handoff_from=tokens)
430
+ ```
431
+
432
+ ### Temporal — the hardest, and the biggest trap
433
+
434
+ ```python
435
+ from temporalio.worker import Worker
436
+ from darkhunt_telemetry.temporal import HandoffInterceptor, current_handoff, child_args
437
+ ```
438
+
439
+ - **Worker**: register ONE `HandoffInterceptor()` in `interceptors=[...]` — it wires
440
+ BOTH the activity side (exposes the inbound token to `current_handoff()`) and the
441
+ workflow side (propagates / relocates per-edge overrides into the Temporal Header).
442
+ - **Activities** hold ALL the telemetry: open `dh.trace(handoff_from=current_handoff()
443
+ or [])`, wrap chat/tools, `trace.end()` in `finally`. `current_handoff()` **works
444
+ inside SYNC activities** run in a `ThreadPoolExecutor` — temporalio copies the
445
+ contextvar context into the worker thread (verified against
446
+ `temporalio.worker._activity`). No async requirement.
447
+ - **NEVER put telemetry in workflow code** — it's a deterministic sandbox (no SDK,
448
+ no `dh.trace`). Its only Darkhunt reference is `child_args`, imported from the
449
+ **sandbox-safe** `darkhunt_telemetry.temporal.handoff_header` subpath (pure dict
450
+ helper, no `temporalio` import) inside `with workflow.unsafe.imports_passed_through():`.
451
+ - **⚠️ RETURN the token to build the DAG.** The interceptor defaults nest every
452
+ child under the coordinator's inbound token → a **star** (the call graph), not the
453
+ causal DAG. A workflow can neither mint nor read a token (sandboxed); only an
454
+ **activity** produces one. A Temporal Header flows parent→child only — so a child's
455
+ token comes home exactly one way: the **activity's return value**. Add an optional
456
+ `handoff` field to each activity result; the coordinator threads it into the next
457
+ `execute_child_workflow` via `child_args`:
458
+
459
+ ```python
460
+ # activity returns its token:
461
+ return {**result, "handoff": trace.handoff_token() if trace else None}
462
+
463
+ # coordinator threads it per edge (child_args attaches a hidden override the
464
+ # interceptor relocates to the header and strips before the child sees it):
465
+ def edge(x, upstream): return child_args(x, [t for t in upstream if t])
466
+ plan = await execute_child_workflow(ReconWf.run, edge(recon_in, [coord_tok]))
467
+ spine = plan.get("handoff")
468
+ batch = await asyncio.gather(*[execute_child_workflow(AnalyzerWf.run, edge(a_in, [spine])) for ...])
469
+ a_toks = [b.get("handoff") for b in batch] # this round's analyzer tokens
470
+ taint = await execute_child_workflow(TaintWf.run, edge(taint_in, a_toks)) # fan-in = array
471
+ decision = await execute_child_workflow(PlannerWf.run, edge(plan_in, a_toks))
472
+ spine = decision.get("handoff") or spine # loop advances the spine
473
+ ```
474
+
475
+ Rules of thumb: **fan-in** = pass the array of upstream tokens; a **loop** = later
476
+ rounds thread the downstream stage's token (advance a `spine` var); a **hub** (a
477
+ guardrail gate fired N times) = keep it on the coordinator's token.
478
+ A collection-returning activity (`list[Finding]`) can't carry a `handoff` field —
479
+ **wrap it** in `{"findings": [...], "handoff": ...}`.
480
+ - **The gateway→coordinator edge needs a hand-rolled client interceptor** — the SDK
481
+ ships workflow + activity interceptors but NO client interceptor, so
482
+ `client.start_workflow(...)` carries no header on its own. Inject the gateway
483
+ trace's token (from an ambient store the request handler sets):
484
+
485
+ ```python
486
+ import temporalio.converter
487
+ from temporalio.client import Client, Interceptor, OutboundInterceptor, StartWorkflowInput
488
+ from darkhunt_telemetry.temporal import HANDOFF_HEADER
489
+
490
+ class _GwOut(OutboundInterceptor):
491
+ async def start_workflow(self, input: StartWorkflowInput):
492
+ token = _gateway_handoff.get() # a contextvars.ContextVar the handler set
493
+ if token:
494
+ input.headers = {**(input.headers or {}),
495
+ HANDOFF_HEADER: temporalio.converter.default().payload_converter.to_payload([token])}
496
+ return await self.next.start_workflow(input)
497
+ class _GwInt(Interceptor):
498
+ def intercept_client(self, next): return _GwOut(next)
499
+ client = await Client.connect(addr, namespace=ns, interceptors=[_GwInt()])
500
+ ```
501
+
502
+ ### The orchestrator / gateway node
503
+
504
+ Ingestion retains a trace ROOT even when contentless (it anchors the topology), so a
505
+ gateway that only fans work out survives on its own — open the root, put the task on
506
+ its `input`, and hand off from `root.handoff_token()` into the first agent:
507
+
508
+ ```python
509
+ root = dh.trace(name=f"{domain}.gateway", session_id=task_id, user_id=user_id, input={"task": task})
510
+ first_token = root.handoff_token() # → first agent's handoff_from
511
+ ```
512
+
513
+ ### Edge rules (each was a real bug)
514
+
515
+ - **Link to the REAL producing agent, not the orchestrator.** Thread the token
516
+ wherever one agent's output becomes the next agent's input. Linking a downstream
517
+ agent to the *orchestrator* (because it spawned it) draws a plausible-but-WRONG
518
+ graph (e.g. `advisor` linked to `coordinator` instead of downstream of `geodata`).
519
+ - **Agent vs Worker**: a node with ≥1 `generation` renders as an **Agent** (model +
520
+ cost); a tools-only node is a **Worker** (no cost). Emit `trace.generation(...)`
521
+ for EVERY real LLM call, even "boilerplate" ones, or their cost never surfaces.
522
+ - **Deep repeat loops → self-loops (`↻ ×N`), not per-round back-edges.** For an
523
+ N-round loop over M agents, link every round to the SAME stable upstream so they
524
+ render as clean self-loops; linking each round to the prior round's output emits a
525
+ tangle of back-edges.
526
+ - **Small 2-agent cycle (`↺`)** — here a back-edge IS right (a retried step links the
527
+ step it retries; a debate rebuttal links the opposing thesis).
528
+ - **Fan-in is `handoff_from=[a, b, c]`** — `[0]` is the parent edge, the rest are
529
+ links.
530
+ - **Logical coupling through a datastore/queue ≠ a drawn edge.** Services related
531
+ only through a shared DB / bucket / a queue the token does NOT ride on have no
532
+ `parentSpanId` chain, so they render as **disjoint islands** — frequently correct
533
+ (classic RAG `ingest`→`answer`). **Do NOT synthesize an edge, and tell the user
534
+ explicitly** that connecting them is an *architecture change* (carry a
535
+ `handoff_token()` across the medium), not a telemetry setting.
536
+
537
+ ## Verification
538
+
539
+ After wiring:
540
+
541
+ 1. **Types/lint**: `uv run python -m compileall src && uv run ruff check .` (or the
542
+ project's checks), and import every entrypoint.
543
+ 2. **Graceful no-op**: run with `DARKHUNT_ENABLED=false` and confirm the app still
544
+ runs and every helper no-ops.
545
+ 3. **Server-side auth/routing probe** — the ONLY reliable programmatic check (a clean
546
+ `flush()`/`shutdown()` is NOT proof of ingestion; the BatchSpanProcessor swallows
547
+ export errors). A **400** on an empty body = auth + routing OK; **401** = wrong/
548
+ absent key; **404** = missing `/trace-hub`:
549
+
550
+ ```bash
551
+ curl -s -o /dev/null -w '%{http_code}\n' -X POST \
552
+ -H "Authorization: Bearer $DARKHUNT_API_KEY" \
553
+ -H 'Content-Type: application/x-protobuf' \
554
+ -H "X-Workspace-Id: $DARKHUNT_WORKSPACE_ID" \
555
+ -H "X-Application-Id: $DARKHUNT_APPLICATION_ID" \
556
+ --data-binary '' \
557
+ "$DARKHUNT_BASE_URL/otlp/t/$DARKHUNT_TENANT_ID/v1/traces"
558
+ ```
559
+
560
+ 4. **Run the REAL instrumented path** and read its node in the dashboard. Verify with
561
+ the same `service.name` as the integration — a probe under a *different*
562
+ `serviceName` mints a **permanent phantom node** (there's no delete-trace API).
563
+ 5. **The Darkhunt MCP cannot read traces back** — there is no tracing-query tool. So
564
+ the two checks are (1) the curl probe (auth+routing only) and (2) the human
565
+ opening the dashboard. Don't claim you verified ingestion programmatically.
566
+
567
+ The dashboard should show: one session-grouped trace per interaction; generations
568
+ rendered as chat bubbles with `input_messages`/`output_messages`; routing attributes
569
+ on the span detail; and token usage / model / computed cost on generations.
570
+
571
+ ## On completion, report the topology shape to the user
572
+
573
+ Finishing the wiring is not the last step — tell the user what the Topology tab will
574
+ (and won't) show, proactively, before they open it. A **connected** graph (real
575
+ handoffs wired) → name the edges to expect (`coordinator → geodata → advisor`,
576
+ self-loops, fan-in). **Disconnected** nodes (independent processes with no live
577
+ handoff — standalone scripts, or a producer/consumer pair coupled only through a
578
+ datastore) are a **correct** result — state why (no `parentSpanId` chain) and that
579
+ connecting them is an architecture change. For OSS/example repos, persist that note
580
+ in the README too.
581
+
582
+ ## Reference files in temporal-demo-python
583
+
584
+ - `src/demo/telemetry.py` — the singleton/memoized clients + `open_agent_trace` /
585
+ `open_gateway_trace` / `trace_chat` / `trace_gen` / `trace_tool` / lifecycle.
586
+ - `src/demo/handoff_context.py` — the `contextvars` ambient carrier.
587
+ - `domains/weather/` — linear in-process (ambient carrier).
588
+ - `domains/finance/run.py` — orchestrator-driven DAG (fan-in, `trace_gen` for a
589
+ non-`chat()` LLM path).
590
+ - `domains/banking/graph.py` — LangGraph state-threaded handoff.
591
+ - `domains/healthcare/{http,handlers,orchestration}.py` — HTTP `traceparent` header +
592
+ consensus fan-in.
593
+ - `domains/devops/{bus,handlers,orchestration}.py` — Redis-stream field + rca fan-in.
594
+ - `domains/security/{activities,workflows,temporal_worker}.py` + `server.py` —
595
+ Temporal: `HandoffInterceptor`, activities return tokens, coordinator threads
596
+ `child_args` per edge, hand-rolled gateway client interceptor.
597
+
598
+ ## Common pitfalls
599
+
600
+ 1. **Constructing the client per call** → leaks. Memoize per `(application_id,
601
+ service_name)`.
602
+ 2. **`pip install darkhunt-telemetry` from public PyPI** → 404. Use a path/git/private
603
+ dep (§1).
604
+ 3. **No signal-driven shutdown** → SIGTERM loses the in-memory batch. Wire
605
+ `shutdown()` in a `finally`.
606
+ 4. **Missing `start_time` on the MANUAL form** → ~0ms duration. Prefer the
607
+ `start_active_*` form (auto-timed); if manual, backdate with `start_time` in epoch
608
+ **seconds**.
609
+ 5. **`base_url` without `/trace-hub`** → 404 (esp. when sourced from
610
+ `credentials.json apiBaseUrl`, which omits it).
611
+ 6. **Reusing an existing app / dropping to raw REST.** Create a NEW OBSERVABILITY app
612
+ via the MCP.
613
+ 7. **Telemetry in Temporal workflow code** → sandbox violation / non-determinism.
614
+ Telemetry lives in activities + the gateway; workflows only reference the
615
+ sandbox-safe `child_args`.
616
+ 8. **Temporal star instead of the DAG** → you didn't RETURN activity tokens +
617
+ `child_args` them per edge. See the Temporal section.
618
+ 9. **Disconnected nodes handed over without explanation** → reads as broken. Report
619
+ the topology shape (correct-when-independent) proactively.
620
+ 10. **Verifying with a throwaway probe under a different `serviceName`** → permanent
621
+ phantom node. Use the curl probe + the real path.