semanticembed 0.6.0__tar.gz → 0.7.0__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 (71) hide show
  1. {semanticembed-0.6.0 → semanticembed-0.7.0}/CHANGELOG.md +15 -0
  2. {semanticembed-0.6.0 → semanticembed-0.7.0}/PKG-INFO +16 -3
  3. {semanticembed-0.6.0 → semanticembed-0.7.0}/README.md +15 -2
  4. {semanticembed-0.6.0 → semanticembed-0.7.0}/pyproject.toml +1 -1
  5. {semanticembed-0.6.0 → semanticembed-0.7.0}/src/semanticembed/__init__.py +1 -1
  6. semanticembed-0.7.0/src/semanticembed/live.py +397 -0
  7. semanticembed-0.7.0/tests/test_live.py +351 -0
  8. semanticembed-0.6.0/src/semanticembed/live.py +0 -117
  9. semanticembed-0.6.0/tests/test_live.py +0 -136
  10. {semanticembed-0.6.0 → semanticembed-0.7.0}/.claude/skills/analyze/SKILL.md +0 -0
  11. {semanticembed-0.6.0 → semanticembed-0.7.0}/.github/workflows/publish.yml +0 -0
  12. {semanticembed-0.6.0 → semanticembed-0.7.0}/.gitignore +0 -0
  13. {semanticembed-0.6.0 → semanticembed-0.7.0}/LICENSE +0 -0
  14. {semanticembed-0.6.0 → semanticembed-0.7.0}/docs/api_reference.md +0 -0
  15. {semanticembed-0.6.0 → semanticembed-0.7.0}/docs/dimensions.md +0 -0
  16. {semanticembed-0.6.0 → semanticembed-0.7.0}/docs/getting_started.md +0 -0
  17. {semanticembed-0.6.0 → semanticembed-0.7.0}/docs/input_format.md +0 -0
  18. {semanticembed-0.6.0 → semanticembed-0.7.0}/docs/license_keys.md +0 -0
  19. {semanticembed-0.6.0 → semanticembed-0.7.0}/docs/output_format.md +0 -0
  20. {semanticembed-0.6.0 → semanticembed-0.7.0}/examples/ai_agent_pipeline.json +0 -0
  21. {semanticembed-0.6.0 → semanticembed-0.7.0}/examples/cicd_pipeline.json +0 -0
  22. {semanticembed-0.6.0 → semanticembed-0.7.0}/examples/google_online_boutique.json +0 -0
  23. {semanticembed-0.6.0 → semanticembed-0.7.0}/examples/react/README.md +0 -0
  24. {semanticembed-0.6.0 → semanticembed-0.7.0}/examples/react/RadarChart.tsx +0 -0
  25. {semanticembed-0.6.0 → semanticembed-0.7.0}/examples/react/RiskTable.tsx +0 -0
  26. {semanticembed-0.6.0 → semanticembed-0.7.0}/examples/react/TopologySummary.tsx +0 -0
  27. {semanticembed-0.6.0 → semanticembed-0.7.0}/examples/react/useSemanticEmbed.ts +0 -0
  28. {semanticembed-0.6.0 → semanticembed-0.7.0}/examples/sample_pipeline.json +0 -0
  29. {semanticembed-0.6.0 → semanticembed-0.7.0}/examples/weaveworks_sock_shop.json +0 -0
  30. {semanticembed-0.6.0 → semanticembed-0.7.0}/notebooks/01_quickstart.ipynb +0 -0
  31. {semanticembed-0.6.0 → semanticembed-0.7.0}/notebooks/02_dimensions.ipynb +0 -0
  32. {semanticembed-0.6.0 → semanticembed-0.7.0}/notebooks/03_drift_detection.ipynb +0 -0
  33. {semanticembed-0.6.0 → semanticembed-0.7.0}/notebooks/04_bring_your_own.ipynb +0 -0
  34. {semanticembed-0.6.0 → semanticembed-0.7.0}/notebooks/05_ai_agent_pipelines.ipynb +0 -0
  35. {semanticembed-0.6.0 → semanticembed-0.7.0}/notebooks/06_cicd_pipelines.ipynb +0 -0
  36. {semanticembed-0.6.0 → semanticembed-0.7.0}/notebooks/07_opentelemetry.ipynb +0 -0
  37. {semanticembed-0.6.0 → semanticembed-0.7.0}/notebooks/08_qwen_compression.ipynb +0 -0
  38. {semanticembed-0.6.0 → semanticembed-0.7.0}/notebooks/qwen_compression_cookbook.py +0 -0
  39. {semanticembed-0.6.0 → semanticembed-0.7.0}/skill/SETUP.md +0 -0
  40. {semanticembed-0.6.0 → semanticembed-0.7.0}/skill/SKILL.md +0 -0
  41. {semanticembed-0.6.0 → semanticembed-0.7.0}/skill/analyze.py +0 -0
  42. {semanticembed-0.6.0 → semanticembed-0.7.0}/src/semanticembed/agent/README.md +0 -0
  43. {semanticembed-0.6.0 → semanticembed-0.7.0}/src/semanticembed/agent/__init__.py +0 -0
  44. {semanticembed-0.6.0 → semanticembed-0.7.0}/src/semanticembed/agent/__main__.py +0 -0
  45. {semanticembed-0.6.0 → semanticembed-0.7.0}/src/semanticembed/agent/agent.py +0 -0
  46. {semanticembed-0.6.0 → semanticembed-0.7.0}/src/semanticembed/agent/gemini_agent.py +0 -0
  47. {semanticembed-0.6.0 → semanticembed-0.7.0}/src/semanticembed/client.py +0 -0
  48. {semanticembed-0.6.0 → semanticembed-0.7.0}/src/semanticembed/dedupe.py +0 -0
  49. {semanticembed-0.6.0 → semanticembed-0.7.0}/src/semanticembed/exceptions.py +0 -0
  50. {semanticembed-0.6.0 → semanticembed-0.7.0}/src/semanticembed/explain.py +0 -0
  51. {semanticembed-0.6.0 → semanticembed-0.7.0}/src/semanticembed/extract.py +0 -0
  52. {semanticembed-0.6.0 → semanticembed-0.7.0}/src/semanticembed/find_edges.py +0 -0
  53. {semanticembed-0.6.0 → semanticembed-0.7.0}/src/semanticembed/models.py +0 -0
  54. {semanticembed-0.6.0 → semanticembed-0.7.0}/tests/__init__.py +0 -0
  55. {semanticembed-0.6.0 → semanticembed-0.7.0}/tests/fixtures/autogen_app.py +0 -0
  56. {semanticembed-0.6.0 → semanticembed-0.7.0}/tests/fixtures/cdk_app.py +0 -0
  57. {semanticembed-0.6.0 → semanticembed-0.7.0}/tests/fixtures/crewai_app.py +0 -0
  58. {semanticembed-0.6.0 → semanticembed-0.7.0}/tests/fixtures/docker-compose.yml +0 -0
  59. {semanticembed-0.6.0 → semanticembed-0.7.0}/tests/fixtures/langgraph_app.py +0 -0
  60. {semanticembed-0.6.0 → semanticembed-0.7.0}/tests/fixtures/pulumi_app.py +0 -0
  61. {semanticembed-0.6.0 → semanticembed-0.7.0}/tests/fixtures/stack.yaml +0 -0
  62. {semanticembed-0.6.0 → semanticembed-0.7.0}/tests/fixtures/trace_jaeger.json +0 -0
  63. {semanticembed-0.6.0 → semanticembed-0.7.0}/tests/fixtures/trace_otlp.json +0 -0
  64. {semanticembed-0.6.0 → semanticembed-0.7.0}/tests/fixtures/trace_zipkin.json +0 -0
  65. {semanticembed-0.6.0 → semanticembed-0.7.0}/tests/test_client.py +0 -0
  66. {semanticembed-0.6.0 → semanticembed-0.7.0}/tests/test_dedupe.py +0 -0
  67. {semanticembed-0.6.0 → semanticembed-0.7.0}/tests/test_extract.py +0 -0
  68. {semanticembed-0.6.0 → semanticembed-0.7.0}/tests/test_extract_ai.py +0 -0
  69. {semanticembed-0.6.0 → semanticembed-0.7.0}/tests/test_extract_iac.py +0 -0
  70. {semanticembed-0.6.0 → semanticembed-0.7.0}/tests/test_extract_otel.py +0 -0
  71. {semanticembed-0.6.0 → semanticembed-0.7.0}/tests/test_skill.py +0 -0
@@ -2,6 +2,21 @@
2
2
 
3
3
  All notable changes to the SemanticEmbed SDK.
4
4
 
5
+ ## 0.7.0 — Honeycomb + Datadog live connectors
6
+
7
+ - New: `semanticembed.live.from_honeycomb(dataset, api_key)` — runs a
8
+ Honeycomb Query API request that breaks down spans by `trace.span_id`,
9
+ `trace.parent_id`, and `service.name`, then derives parent-child
10
+ service edges. Polls the result endpoint until complete.
11
+ Override `api_url` for EU tenants.
12
+ - New: `semanticembed.live.from_datadog(api_key, app_key)` — calls the
13
+ Spans Search API (`POST /api/v2/spans/events/search`), paginates via
14
+ `meta.page.after`, applies the same span-row → service-edge join.
15
+ Optional `env` and `service` filters; `site` override for EU/US3/US5.
16
+ - Both raise clear `ValueError`s when credentials are missing; both fall
17
+ back to canonical env vars (`HONEYCOMB_API_KEY` / `HONEYCOMB_DATASET`,
18
+ `DD_API_KEY` / `DD_APP_KEY`).
19
+
5
20
  ## 0.6.0 — Infrastructure-as-code parsers
6
21
 
7
22
  - New: `se.extract.from_cloudformation(template)` — parses YAML/JSON templates,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: semanticembed
3
- Version: 0.6.0
3
+ Version: 0.7.0
4
4
  Summary: 6D structural intelligence for directed graphs. Six numbers per node. Sub-millisecond.
5
5
  Project-URL: Homepage, https://github.com/jmurray10/semanticembed-sdk
6
6
  Project-URL: Documentation, https://github.com/jmurray10/semanticembed-sdk
@@ -258,9 +258,22 @@ edges = live.from_dynatrace(
258
258
  env_url="https://abc12345.live.dynatrace.com",
259
259
  api_token=os.environ["DYNATRACE_API_TOKEN"],
260
260
  )
261
- ```
262
261
 
263
- Honeycomb and Datadog connectors are tracked for future releases.
262
+ # Honeycomb — Query API over a dataset
263
+ edges = live.from_honeycomb(
264
+ dataset="my-app-prod",
265
+ api_key=os.environ["HONEYCOMB_API_KEY"],
266
+ lookback_seconds=900,
267
+ )
268
+
269
+ # Datadog — Spans Search API
270
+ edges = live.from_datadog(
271
+ api_key=os.environ["DD_API_KEY"],
272
+ app_key=os.environ["DD_APP_KEY"],
273
+ env="prod",
274
+ lookback="now-30m",
275
+ )
276
+ ```
264
277
 
265
278
  ### AI agent frameworks
266
279
 
@@ -218,9 +218,22 @@ edges = live.from_dynatrace(
218
218
  env_url="https://abc12345.live.dynatrace.com",
219
219
  api_token=os.environ["DYNATRACE_API_TOKEN"],
220
220
  )
221
- ```
222
221
 
223
- Honeycomb and Datadog connectors are tracked for future releases.
222
+ # Honeycomb — Query API over a dataset
223
+ edges = live.from_honeycomb(
224
+ dataset="my-app-prod",
225
+ api_key=os.environ["HONEYCOMB_API_KEY"],
226
+ lookback_seconds=900,
227
+ )
228
+
229
+ # Datadog — Spans Search API
230
+ edges = live.from_datadog(
231
+ api_key=os.environ["DD_API_KEY"],
232
+ app_key=os.environ["DD_APP_KEY"],
233
+ env="prod",
234
+ lookback="now-30m",
235
+ )
236
+ ```
224
237
 
225
238
  ### AI agent frameworks
226
239
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "semanticembed"
7
- version = "0.6.0"
7
+ version = "0.7.0"
8
8
  description = "6D structural intelligence for directed graphs. Six numbers per node. Sub-millisecond."
9
9
  readme = "README.md"
10
10
  license = {text = "Proprietary"}
@@ -17,7 +17,7 @@ from .dedupe import dedupe_edges
17
17
  from .explain import explain, ask
18
18
  from .find_edges import find_edges
19
19
 
20
- __version__ = "0.6.0"
20
+ __version__ = "0.7.0"
21
21
 
22
22
  # Set this to your license key to unlock unlimited nodes:
23
23
  # import semanticembed
@@ -0,0 +1,397 @@
1
+ """Live observability connectors — fetch real call edges from running infra.
2
+
3
+ Unlike :mod:`semanticembed.extract`, the functions in this module make
4
+ **outbound HTTP requests** to third-party observability APIs. Each returns
5
+ the same shape as the local extractors: ``list[tuple[str, str]]``.
6
+
7
+ For SaaS APIs that require auth, credentials may be passed explicitly or
8
+ read from the canonical environment variable for that vendor.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ from typing import Any
15
+
16
+ import httpx
17
+
18
+ from .extract import _dedupe
19
+
20
+
21
+ __all__ = ["from_dynatrace", "from_honeycomb", "from_datadog"]
22
+
23
+
24
+ # ---------------------------------------------------------------------------
25
+ # Dynatrace
26
+ # ---------------------------------------------------------------------------
27
+
28
+
29
+ def from_dynatrace(
30
+ env_url: str | None = None,
31
+ *,
32
+ api_token: str | None = None,
33
+ timeout: float = 30.0,
34
+ ) -> list[tuple[str, str]]:
35
+ """Fetch service-to-service call edges from a Dynatrace environment.
36
+
37
+ Queries Smartscape (Environment API v2) for ``type("SERVICE")`` entities
38
+ and their ``fromRelationships.calls`` references, then maps those to
39
+ edges using each entity's ``displayName``.
40
+
41
+ Args:
42
+ env_url: The base URL of the Dynatrace environment, e.g.
43
+ ``https://abc12345.live.dynatrace.com``. If omitted, falls back
44
+ to ``DYNATRACE_ENV_URL`` from the environment.
45
+ api_token: A Dynatrace API token with at least ``entities.read``
46
+ scope. If omitted, falls back to ``DYNATRACE_API_TOKEN``.
47
+ timeout: Per-request timeout in seconds.
48
+
49
+ Returns:
50
+ Deduplicated list of ``(source_service, target_service)`` edges.
51
+
52
+ Raises:
53
+ ValueError: if env_url or api_token cannot be resolved.
54
+ httpx.HTTPStatusError: on a 4xx/5xx response from Dynatrace.
55
+
56
+ Example::
57
+
58
+ import semanticembed as se
59
+ from semanticembed import live
60
+
61
+ edges = live.from_dynatrace(
62
+ env_url="https://abc12345.live.dynatrace.com",
63
+ api_token=os.environ["DYNATRACE_API_TOKEN"],
64
+ )
65
+ result = se.encode(edges)
66
+ """
67
+ env_url = (env_url or os.environ.get("DYNATRACE_ENV_URL") or "").rstrip("/")
68
+ api_token = api_token or os.environ.get("DYNATRACE_API_TOKEN")
69
+ if not env_url:
70
+ raise ValueError("env_url not provided and DYNATRACE_ENV_URL is unset")
71
+ if not api_token:
72
+ raise ValueError("api_token not provided and DYNATRACE_API_TOKEN is unset")
73
+
74
+ headers = {"Authorization": f"Api-Token {api_token}"}
75
+ id_to_name: dict[str, str] = {}
76
+ entities: list[dict[str, Any]] = []
77
+
78
+ next_page_key: str | None = None
79
+ with httpx.Client(timeout=timeout) as client:
80
+ while True:
81
+ if next_page_key:
82
+ params: dict[str, Any] = {"nextPageKey": next_page_key}
83
+ else:
84
+ params = {
85
+ "entitySelector": 'type("SERVICE")',
86
+ "fields": "fromRelationships.calls",
87
+ "pageSize": 500,
88
+ }
89
+ resp = client.get(
90
+ f"{env_url}/api/v2/entities", headers=headers, params=params
91
+ )
92
+ resp.raise_for_status()
93
+ data = resp.json()
94
+
95
+ for entity in data.get("entities", []):
96
+ eid = entity.get("entityId")
97
+ if not eid:
98
+ continue
99
+ id_to_name[eid] = entity.get("displayName", eid)
100
+ entities.append(entity)
101
+
102
+ next_page_key = data.get("nextPageKey")
103
+ if not next_page_key:
104
+ break
105
+
106
+ edges: list[tuple[str, str]] = []
107
+ for entity in entities:
108
+ src = entity.get("displayName", entity.get("entityId", ""))
109
+ calls = (entity.get("fromRelationships") or {}).get("calls", [])
110
+ for call in calls:
111
+ target_id = call.get("id")
112
+ if not target_id:
113
+ continue
114
+ target = id_to_name.get(target_id)
115
+ if target:
116
+ edges.append((src, target))
117
+ return _dedupe(edges)
118
+
119
+
120
+ # ---------------------------------------------------------------------------
121
+ # Honeycomb
122
+ # ---------------------------------------------------------------------------
123
+
124
+
125
+ def from_honeycomb(
126
+ dataset: str | None = None,
127
+ *,
128
+ api_key: str | None = None,
129
+ api_url: str = "https://api.honeycomb.io",
130
+ lookback_seconds: int = 3600,
131
+ max_spans: int = 10_000,
132
+ timeout: float = 60.0,
133
+ ) -> list[tuple[str, str]]:
134
+ """Fetch service-to-service edges from a Honeycomb dataset.
135
+
136
+ Issues a Honeycomb Query API request that breaks down spans by
137
+ ``trace.span_id``, ``trace.parent_id``, and ``service.name`` over the
138
+ last ``lookback_seconds``. Builds an in-memory ``span_id -> service``
139
+ map, then emits one edge per parent-child pair where the parent and
140
+ child are different services.
141
+
142
+ Args:
143
+ dataset: Honeycomb dataset slug (e.g. ``"my-app-prod"``). Falls
144
+ back to ``HONEYCOMB_DATASET``.
145
+ api_key: Honeycomb API key. Needs the "Run queries" permission.
146
+ Falls back to ``HONEYCOMB_API_KEY``.
147
+ api_url: API base. Override for EU tenants
148
+ (``https://api.eu1.honeycomb.io``).
149
+ lookback_seconds: Time window to query.
150
+ max_spans: Server-side LIMIT on rows returned.
151
+ timeout: Per-request timeout (covers both create-query and
152
+ poll-results, separately).
153
+
154
+ Returns:
155
+ Deduped list of ``(parent_service, child_service)`` edges.
156
+
157
+ Raises:
158
+ ValueError: if dataset or api_key cannot be resolved.
159
+ RuntimeError: if the query result polls past the timeout.
160
+ httpx.HTTPStatusError: on a 4xx/5xx response.
161
+
162
+ Example::
163
+
164
+ from semanticembed import live
165
+ edges = live.from_honeycomb(
166
+ dataset="my-app-prod",
167
+ api_key=os.environ["HONEYCOMB_API_KEY"],
168
+ lookback_seconds=900,
169
+ )
170
+ """
171
+ import time as _time
172
+
173
+ dataset = dataset or os.environ.get("HONEYCOMB_DATASET")
174
+ api_key = api_key or os.environ.get("HONEYCOMB_API_KEY")
175
+ if not dataset:
176
+ raise ValueError("dataset not provided and HONEYCOMB_DATASET is unset")
177
+ if not api_key:
178
+ raise ValueError("api_key not provided and HONEYCOMB_API_KEY is unset")
179
+
180
+ api_url = api_url.rstrip("/")
181
+ headers = {"X-Honeycomb-Team": api_key, "Content-Type": "application/json"}
182
+ query_body = {
183
+ "calculations": [{"op": "COUNT"}],
184
+ "breakdowns": ["trace.span_id", "trace.parent_id", "service.name"],
185
+ "filters": [{"column": "trace.span_id", "op": "exists"}],
186
+ "filter_combination": "AND",
187
+ "time_range": lookback_seconds,
188
+ "limit": max_spans,
189
+ }
190
+
191
+ with httpx.Client(timeout=timeout) as client:
192
+ # 1) Create the query.
193
+ r1 = client.post(
194
+ f"{api_url}/1/queries/{dataset}", headers=headers, json=query_body
195
+ )
196
+ r1.raise_for_status()
197
+ query_id = r1.json().get("id")
198
+ if not query_id:
199
+ raise RuntimeError("Honeycomb create-query returned no id")
200
+
201
+ # 2) Start the result and poll until complete.
202
+ r2 = client.post(
203
+ f"{api_url}/1/query_results/{dataset}",
204
+ headers=headers,
205
+ json={"query_id": query_id, "disable_series": True, "limit": max_spans},
206
+ )
207
+ r2.raise_for_status()
208
+ result_id = r2.json().get("id")
209
+ if not result_id:
210
+ raise RuntimeError("Honeycomb start-result returned no id")
211
+
212
+ deadline = _time.monotonic() + timeout
213
+ rows: list[dict[str, Any]] = []
214
+ while _time.monotonic() < deadline:
215
+ r3 = client.get(
216
+ f"{api_url}/1/query_results/{dataset}/{result_id}", headers=headers
217
+ )
218
+ r3.raise_for_status()
219
+ payload = r3.json()
220
+ if payload.get("complete"):
221
+ rows = (payload.get("data") or {}).get("results", []) or []
222
+ break
223
+ _time.sleep(1.0)
224
+ else:
225
+ raise RuntimeError(
226
+ f"Honeycomb query did not complete within {timeout}s"
227
+ )
228
+
229
+ # Honeycomb wraps each row as {"data": {breakdown: value, ...}}; unwrap for the helper.
230
+ flat_rows = [r.get("data", r) for r in rows]
231
+ return _edges_from_span_rows(
232
+ flat_rows,
233
+ span_id_key="trace.span_id",
234
+ parent_id_key="trace.parent_id",
235
+ service_key="service.name",
236
+ )
237
+
238
+
239
+ # ---------------------------------------------------------------------------
240
+ # Datadog
241
+ # ---------------------------------------------------------------------------
242
+
243
+
244
+ def from_datadog(
245
+ *,
246
+ api_key: str | None = None,
247
+ app_key: str | None = None,
248
+ site: str = "datadoghq.com",
249
+ lookback: str = "now-1h",
250
+ max_spans: int = 1000,
251
+ env: str | None = None,
252
+ service: str | None = None,
253
+ timeout: float = 30.0,
254
+ ) -> list[tuple[str, str]]:
255
+ """Fetch service-to-service edges from Datadog APM spans.
256
+
257
+ Calls the Spans Search API (``POST /api/v2/spans/events/search``) and
258
+ derives edges from span ``parent_id`` references. Same join logic as
259
+ the OTEL trace parser: build a ``span_id -> service`` map, emit
260
+ ``(parent_service, child_service)`` for each parent-child pair where
261
+ the services differ.
262
+
263
+ Args:
264
+ api_key: Datadog API key. Falls back to ``DD_API_KEY`` /
265
+ ``DATADOG_API_KEY``.
266
+ app_key: Datadog application key (required for span search).
267
+ Falls back to ``DD_APP_KEY`` / ``DATADOG_APP_KEY``.
268
+ site: Datadog tenant site. Default ``datadoghq.com`` (US1). EU
269
+ users pass ``datadoghq.eu``; US3/US5 pass ``us3.datadoghq.com``
270
+ etc.
271
+ lookback: Datadog relative time string for the start of the
272
+ search window. Default ``"now-1h"``.
273
+ max_spans: Page-size cap (Datadog enforces 1000 max per page).
274
+ env: Optional ``env`` filter (e.g. ``"prod"``).
275
+ service: Optional service-name filter (limits to spans whose
276
+ ``service`` matches; useful for debugging a single service's
277
+ upstream/downstream).
278
+ timeout: Per-request timeout.
279
+
280
+ Returns:
281
+ Deduped list of ``(parent_service, child_service)`` edges.
282
+
283
+ Raises:
284
+ ValueError: if api_key or app_key cannot be resolved.
285
+ httpx.HTTPStatusError: on a 4xx/5xx response.
286
+
287
+ Example::
288
+
289
+ from semanticembed import live
290
+ edges = live.from_datadog(
291
+ env="prod",
292
+ lookback="now-30m",
293
+ )
294
+ """
295
+ api_key = api_key or os.environ.get("DD_API_KEY") or os.environ.get("DATADOG_API_KEY")
296
+ app_key = app_key or os.environ.get("DD_APP_KEY") or os.environ.get("DATADOG_APP_KEY")
297
+ if not api_key:
298
+ raise ValueError("api_key not provided and DD_API_KEY is unset")
299
+ if not app_key:
300
+ raise ValueError("app_key not provided and DD_APP_KEY is unset")
301
+
302
+ headers = {
303
+ "DD-API-KEY": api_key,
304
+ "DD-APPLICATION-KEY": app_key,
305
+ "Content-Type": "application/json",
306
+ }
307
+
308
+ query_parts = []
309
+ if env:
310
+ query_parts.append(f"env:{env}")
311
+ if service:
312
+ query_parts.append(f"service:{service}")
313
+ query = " ".join(query_parts) if query_parts else "*"
314
+
315
+ body: dict[str, Any] = {
316
+ "data": {
317
+ "attributes": {
318
+ "filter": {
319
+ "from": lookback,
320
+ "to": "now",
321
+ "query": query,
322
+ },
323
+ "page": {"limit": max_spans},
324
+ "sort": "-timestamp",
325
+ },
326
+ "type": "search_request",
327
+ }
328
+ }
329
+
330
+ rows: list[dict[str, Any]] = []
331
+ next_cursor: str | None = None
332
+ fetched = 0
333
+ with httpx.Client(timeout=timeout) as client:
334
+ while True:
335
+ if next_cursor:
336
+ body["data"]["attributes"]["page"]["cursor"] = next_cursor
337
+ resp = client.post(
338
+ f"https://api.{site}/api/v2/spans/events/search",
339
+ headers=headers,
340
+ json=body,
341
+ )
342
+ resp.raise_for_status()
343
+ payload = resp.json()
344
+ for ev in payload.get("data", []):
345
+ attrs = ev.get("attributes") or {}
346
+ rows.append({
347
+ "span_id": attrs.get("span_id") or ev.get("id"),
348
+ "parent_id": attrs.get("parent_id"),
349
+ "service": attrs.get("service"),
350
+ })
351
+ fetched += 1
352
+ meta = payload.get("meta") or {}
353
+ next_cursor = (meta.get("page") or {}).get("after")
354
+ if not next_cursor or fetched >= max_spans:
355
+ break
356
+
357
+ return _edges_from_span_rows(
358
+ rows, span_id_key="span_id", parent_id_key="parent_id", service_key="service"
359
+ )
360
+
361
+
362
+ # ---------------------------------------------------------------------------
363
+ # Helpers
364
+ # ---------------------------------------------------------------------------
365
+
366
+
367
+ def _edges_from_span_rows(
368
+ rows: list[dict[str, Any]],
369
+ *,
370
+ span_id_key: str,
371
+ parent_id_key: str,
372
+ service_key: str,
373
+ ) -> list[tuple[str, str]]:
374
+ """Two-pass span-row -> service-edge derivation.
375
+
376
+ 1. Build ``span_id -> service`` from every row that has both.
377
+ 2. For every row with a parent_id, look up parent's service and emit
378
+ ``(parent_service, child_service)`` if the services differ.
379
+ """
380
+ span_to_service: dict[str, str] = {}
381
+ for row in rows:
382
+ sid = row.get(span_id_key)
383
+ svc = row.get(service_key)
384
+ if sid and svc:
385
+ span_to_service[str(sid)] = str(svc)
386
+
387
+ edges: list[tuple[str, str]] = []
388
+ for row in rows:
389
+ sid = row.get(span_id_key)
390
+ pid = row.get(parent_id_key)
391
+ if not sid or not pid:
392
+ continue
393
+ child_svc = span_to_service.get(str(sid))
394
+ parent_svc = span_to_service.get(str(pid))
395
+ if parent_svc and child_svc and parent_svc != child_svc:
396
+ edges.append((parent_svc, child_svc))
397
+ return _dedupe(edges)