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.
- {semanticembed-0.6.0 → semanticembed-0.7.0}/CHANGELOG.md +15 -0
- {semanticembed-0.6.0 → semanticembed-0.7.0}/PKG-INFO +16 -3
- {semanticembed-0.6.0 → semanticembed-0.7.0}/README.md +15 -2
- {semanticembed-0.6.0 → semanticembed-0.7.0}/pyproject.toml +1 -1
- {semanticembed-0.6.0 → semanticembed-0.7.0}/src/semanticembed/__init__.py +1 -1
- semanticembed-0.7.0/src/semanticembed/live.py +397 -0
- semanticembed-0.7.0/tests/test_live.py +351 -0
- semanticembed-0.6.0/src/semanticembed/live.py +0 -117
- semanticembed-0.6.0/tests/test_live.py +0 -136
- {semanticembed-0.6.0 → semanticembed-0.7.0}/.claude/skills/analyze/SKILL.md +0 -0
- {semanticembed-0.6.0 → semanticembed-0.7.0}/.github/workflows/publish.yml +0 -0
- {semanticembed-0.6.0 → semanticembed-0.7.0}/.gitignore +0 -0
- {semanticembed-0.6.0 → semanticembed-0.7.0}/LICENSE +0 -0
- {semanticembed-0.6.0 → semanticembed-0.7.0}/docs/api_reference.md +0 -0
- {semanticembed-0.6.0 → semanticembed-0.7.0}/docs/dimensions.md +0 -0
- {semanticembed-0.6.0 → semanticembed-0.7.0}/docs/getting_started.md +0 -0
- {semanticembed-0.6.0 → semanticembed-0.7.0}/docs/input_format.md +0 -0
- {semanticembed-0.6.0 → semanticembed-0.7.0}/docs/license_keys.md +0 -0
- {semanticembed-0.6.0 → semanticembed-0.7.0}/docs/output_format.md +0 -0
- {semanticembed-0.6.0 → semanticembed-0.7.0}/examples/ai_agent_pipeline.json +0 -0
- {semanticembed-0.6.0 → semanticembed-0.7.0}/examples/cicd_pipeline.json +0 -0
- {semanticembed-0.6.0 → semanticembed-0.7.0}/examples/google_online_boutique.json +0 -0
- {semanticembed-0.6.0 → semanticembed-0.7.0}/examples/react/README.md +0 -0
- {semanticembed-0.6.0 → semanticembed-0.7.0}/examples/react/RadarChart.tsx +0 -0
- {semanticembed-0.6.0 → semanticembed-0.7.0}/examples/react/RiskTable.tsx +0 -0
- {semanticembed-0.6.0 → semanticembed-0.7.0}/examples/react/TopologySummary.tsx +0 -0
- {semanticembed-0.6.0 → semanticembed-0.7.0}/examples/react/useSemanticEmbed.ts +0 -0
- {semanticembed-0.6.0 → semanticembed-0.7.0}/examples/sample_pipeline.json +0 -0
- {semanticembed-0.6.0 → semanticembed-0.7.0}/examples/weaveworks_sock_shop.json +0 -0
- {semanticembed-0.6.0 → semanticembed-0.7.0}/notebooks/01_quickstart.ipynb +0 -0
- {semanticembed-0.6.0 → semanticembed-0.7.0}/notebooks/02_dimensions.ipynb +0 -0
- {semanticembed-0.6.0 → semanticembed-0.7.0}/notebooks/03_drift_detection.ipynb +0 -0
- {semanticembed-0.6.0 → semanticembed-0.7.0}/notebooks/04_bring_your_own.ipynb +0 -0
- {semanticembed-0.6.0 → semanticembed-0.7.0}/notebooks/05_ai_agent_pipelines.ipynb +0 -0
- {semanticembed-0.6.0 → semanticembed-0.7.0}/notebooks/06_cicd_pipelines.ipynb +0 -0
- {semanticembed-0.6.0 → semanticembed-0.7.0}/notebooks/07_opentelemetry.ipynb +0 -0
- {semanticembed-0.6.0 → semanticembed-0.7.0}/notebooks/08_qwen_compression.ipynb +0 -0
- {semanticembed-0.6.0 → semanticembed-0.7.0}/notebooks/qwen_compression_cookbook.py +0 -0
- {semanticembed-0.6.0 → semanticembed-0.7.0}/skill/SETUP.md +0 -0
- {semanticembed-0.6.0 → semanticembed-0.7.0}/skill/SKILL.md +0 -0
- {semanticembed-0.6.0 → semanticembed-0.7.0}/skill/analyze.py +0 -0
- {semanticembed-0.6.0 → semanticembed-0.7.0}/src/semanticembed/agent/README.md +0 -0
- {semanticembed-0.6.0 → semanticembed-0.7.0}/src/semanticembed/agent/__init__.py +0 -0
- {semanticembed-0.6.0 → semanticembed-0.7.0}/src/semanticembed/agent/__main__.py +0 -0
- {semanticembed-0.6.0 → semanticembed-0.7.0}/src/semanticembed/agent/agent.py +0 -0
- {semanticembed-0.6.0 → semanticembed-0.7.0}/src/semanticembed/agent/gemini_agent.py +0 -0
- {semanticembed-0.6.0 → semanticembed-0.7.0}/src/semanticembed/client.py +0 -0
- {semanticembed-0.6.0 → semanticembed-0.7.0}/src/semanticembed/dedupe.py +0 -0
- {semanticembed-0.6.0 → semanticembed-0.7.0}/src/semanticembed/exceptions.py +0 -0
- {semanticembed-0.6.0 → semanticembed-0.7.0}/src/semanticembed/explain.py +0 -0
- {semanticembed-0.6.0 → semanticembed-0.7.0}/src/semanticembed/extract.py +0 -0
- {semanticembed-0.6.0 → semanticembed-0.7.0}/src/semanticembed/find_edges.py +0 -0
- {semanticembed-0.6.0 → semanticembed-0.7.0}/src/semanticembed/models.py +0 -0
- {semanticembed-0.6.0 → semanticembed-0.7.0}/tests/__init__.py +0 -0
- {semanticembed-0.6.0 → semanticembed-0.7.0}/tests/fixtures/autogen_app.py +0 -0
- {semanticembed-0.6.0 → semanticembed-0.7.0}/tests/fixtures/cdk_app.py +0 -0
- {semanticembed-0.6.0 → semanticembed-0.7.0}/tests/fixtures/crewai_app.py +0 -0
- {semanticembed-0.6.0 → semanticembed-0.7.0}/tests/fixtures/docker-compose.yml +0 -0
- {semanticembed-0.6.0 → semanticembed-0.7.0}/tests/fixtures/langgraph_app.py +0 -0
- {semanticembed-0.6.0 → semanticembed-0.7.0}/tests/fixtures/pulumi_app.py +0 -0
- {semanticembed-0.6.0 → semanticembed-0.7.0}/tests/fixtures/stack.yaml +0 -0
- {semanticembed-0.6.0 → semanticembed-0.7.0}/tests/fixtures/trace_jaeger.json +0 -0
- {semanticembed-0.6.0 → semanticembed-0.7.0}/tests/fixtures/trace_otlp.json +0 -0
- {semanticembed-0.6.0 → semanticembed-0.7.0}/tests/fixtures/trace_zipkin.json +0 -0
- {semanticembed-0.6.0 → semanticembed-0.7.0}/tests/test_client.py +0 -0
- {semanticembed-0.6.0 → semanticembed-0.7.0}/tests/test_dedupe.py +0 -0
- {semanticembed-0.6.0 → semanticembed-0.7.0}/tests/test_extract.py +0 -0
- {semanticembed-0.6.0 → semanticembed-0.7.0}/tests/test_extract_ai.py +0 -0
- {semanticembed-0.6.0 → semanticembed-0.7.0}/tests/test_extract_iac.py +0 -0
- {semanticembed-0.6.0 → semanticembed-0.7.0}/tests/test_extract_otel.py +0 -0
- {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.
|
|
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
|
|
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
|
|
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.
|
|
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"}
|
|
@@ -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)
|