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.
- darkhunt_telemetry-0.5.13/.claude/skills/darkhunt-telemetry-integration/SKILL.md +621 -0
- darkhunt_telemetry-0.5.13/.github/workflows/ci.yml +129 -0
- darkhunt_telemetry-0.5.13/.gitignore +15 -0
- darkhunt_telemetry-0.5.13/LICENSE +201 -0
- darkhunt_telemetry-0.5.13/NOTICE +19 -0
- darkhunt_telemetry-0.5.13/PKG-INFO +350 -0
- darkhunt_telemetry-0.5.13/README.md +309 -0
- darkhunt_telemetry-0.5.13/darkhunt_telemetry/__init__.py +81 -0
- darkhunt_telemetry-0.5.13/darkhunt_telemetry/_version.py +13 -0
- darkhunt_telemetry-0.5.13/darkhunt_telemetry/attributes.py +62 -0
- darkhunt_telemetry-0.5.13/darkhunt_telemetry/client.py +335 -0
- darkhunt_telemetry-0.5.13/darkhunt_telemetry/exporter.py +203 -0
- darkhunt_telemetry-0.5.13/darkhunt_telemetry/masking/__init__.py +14 -0
- darkhunt_telemetry-0.5.13/darkhunt_telemetry/masking/rules/rules.json +688 -0
- darkhunt_telemetry-0.5.13/darkhunt_telemetry/masking/sanitizer.py +261 -0
- darkhunt_telemetry-0.5.13/darkhunt_telemetry/masking/validators/__init__.py +42 -0
- darkhunt_telemetry-0.5.13/darkhunt_telemetry/masking/validators/aba.py +24 -0
- darkhunt_telemetry-0.5.13/darkhunt_telemetry/masking/validators/base58check.py +46 -0
- darkhunt_telemetry-0.5.13/darkhunt_telemetry/masking/validators/bech32.py +62 -0
- darkhunt_telemetry-0.5.13/darkhunt_telemetry/masking/validators/credit_card.py +85 -0
- darkhunt_telemetry-0.5.13/darkhunt_telemetry/masking/validators/eip55.py +52 -0
- darkhunt_telemetry-0.5.13/darkhunt_telemetry/masking/validators/iban_mod97.py +36 -0
- darkhunt_telemetry-0.5.13/darkhunt_telemetry/masking/validators/keccak.py +128 -0
- darkhunt_telemetry-0.5.13/darkhunt_telemetry/masking/validators/luhn.py +27 -0
- darkhunt_telemetry-0.5.13/darkhunt_telemetry/otel_globals.py +62 -0
- darkhunt_telemetry-0.5.13/darkhunt_telemetry/py.typed +0 -0
- darkhunt_telemetry-0.5.13/darkhunt_telemetry/span.py +664 -0
- darkhunt_telemetry-0.5.13/darkhunt_telemetry/temporal/__init__.py +25 -0
- darkhunt_telemetry-0.5.13/darkhunt_telemetry/temporal/handoff_header.py +60 -0
- darkhunt_telemetry-0.5.13/darkhunt_telemetry/temporal/interceptors.py +150 -0
- darkhunt_telemetry-0.5.13/darkhunt_telemetry/trace.py +277 -0
- darkhunt_telemetry-0.5.13/darkhunt_telemetry/transports/__init__.py +29 -0
- darkhunt_telemetry-0.5.13/darkhunt_telemetry/transports/http.py +65 -0
- darkhunt_telemetry-0.5.13/darkhunt_telemetry/transports/queue.py +87 -0
- darkhunt_telemetry-0.5.13/darkhunt_telemetry/types.py +78 -0
- darkhunt_telemetry-0.5.13/pyproject.toml +114 -0
- darkhunt_telemetry-0.5.13/sonar-project.properties +38 -0
- darkhunt_telemetry-0.5.13/tests/conftest.py +34 -0
- darkhunt_telemetry-0.5.13/tests/test_exporter.py +112 -0
- darkhunt_telemetry-0.5.13/tests/test_handoff.py +81 -0
- darkhunt_telemetry-0.5.13/tests/test_keccak.py +87 -0
- darkhunt_telemetry-0.5.13/tests/test_masking.py +115 -0
- darkhunt_telemetry-0.5.13/tests/test_masking_parity.py +67 -0
- darkhunt_telemetry-0.5.13/tests/test_temporal.py +45 -0
- darkhunt_telemetry-0.5.13/tests/test_tracing.py +187 -0
- darkhunt_telemetry-0.5.13/tests/test_transports.py +58 -0
- 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.
|