forgesight-datadog 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- forgesight_datadog/__init__.py +34 -0
- forgesight_datadog/_ddtrace.py +95 -0
- forgesight_datadog/exporter.py +510 -0
- forgesight_datadog/py.typed +0 -0
- forgesight_datadog/testing.py +69 -0
- forgesight_datadog-0.1.0.dist-info/METADATA +97 -0
- forgesight_datadog-0.1.0.dist-info/RECORD +9 -0
- forgesight_datadog-0.1.0.dist-info/WHEEL +4 -0
- forgesight_datadog-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""ForgeSight Datadog exporter — DD-native APM spans + cost metric (DD Agent or OTLP)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .exporter import (
|
|
6
|
+
COST_METRIC,
|
|
7
|
+
DD_SITES,
|
|
8
|
+
OTLP_NATIVE_BACKENDS,
|
|
9
|
+
TOKENS_METRIC,
|
|
10
|
+
DatadogExporter,
|
|
11
|
+
DatadogMetricSink,
|
|
12
|
+
DatadogSpan,
|
|
13
|
+
DatadogSpanWriter,
|
|
14
|
+
record_to_span,
|
|
15
|
+
)
|
|
16
|
+
from .testing import InMemoryDatadogMetricSink, InMemoryDatadogSpanWriter, MetricCall
|
|
17
|
+
|
|
18
|
+
__version__ = "0.1.0"
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"COST_METRIC",
|
|
22
|
+
"DD_SITES",
|
|
23
|
+
"OTLP_NATIVE_BACKENDS",
|
|
24
|
+
"TOKENS_METRIC",
|
|
25
|
+
"DatadogExporter",
|
|
26
|
+
"DatadogMetricSink",
|
|
27
|
+
"DatadogSpan",
|
|
28
|
+
"DatadogSpanWriter",
|
|
29
|
+
"InMemoryDatadogMetricSink",
|
|
30
|
+
"InMemoryDatadogSpanWriter",
|
|
31
|
+
"MetricCall",
|
|
32
|
+
"__version__",
|
|
33
|
+
"record_to_span",
|
|
34
|
+
]
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""Vendor-backed defaults for the ``agent`` transport — ``ddtrace`` + dogstatsd.
|
|
2
|
+
|
|
3
|
+
These touch the live Datadog SDK / Agent, so they are exercised only against a real DD
|
|
4
|
+
Agent (every line is ``pragma: no cover``). The pure record→span mapping lives in
|
|
5
|
+
:mod:`forgesight_datadog.exporter` and is fully unit-tested via injected doubles; this
|
|
6
|
+
module is the thin edge that pushes a mapped :class:`DatadogSpan` onto a ``ddtrace`` span
|
|
7
|
+
and a DD metric onto dogstatsd.
|
|
8
|
+
|
|
9
|
+
Vendor access goes through an ``Any``-typed dynamic boundary on purpose: the package
|
|
10
|
+
supports ``ddtrace>=2`` and the exact span/writer API drifts across major versions, so we
|
|
11
|
+
resolve it at runtime (against whatever ``ddtrace`` is installed) rather than pinning to
|
|
12
|
+
one version's symbols at type-check time.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import contextlib
|
|
18
|
+
import importlib
|
|
19
|
+
from collections.abc import Sequence
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from .exporter import DatadogSpan
|
|
23
|
+
|
|
24
|
+
_DD_64BIT_MASK = (1 << 64) - 1
|
|
25
|
+
_NANOS_PER_S = 1_000_000_000
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class DDTraceSpanWriter: # pragma: no cover - requires a live DD Agent
|
|
29
|
+
"""Writes mapped spans to a DD Agent via ``ddtrace``'s public tracer."""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
*,
|
|
34
|
+
service: str,
|
|
35
|
+
api_key: str | None,
|
|
36
|
+
site: str,
|
|
37
|
+
agent_endpoint: str | None,
|
|
38
|
+
) -> None:
|
|
39
|
+
trace_mod: Any = importlib.import_module("ddtrace.trace")
|
|
40
|
+
self._tracer: Any = trace_mod.tracer
|
|
41
|
+
if agent_endpoint:
|
|
42
|
+
# best-effort; the tracer falls back to its default agent url
|
|
43
|
+
with contextlib.suppress(Exception):
|
|
44
|
+
self._tracer.configure(hostname=None, port=None, url=agent_endpoint)
|
|
45
|
+
self._service = service
|
|
46
|
+
|
|
47
|
+
def write(self, span: DatadogSpan) -> None:
|
|
48
|
+
dd = self._tracer.start_span(
|
|
49
|
+
span.name, service=span.service, resource=span.resource, activate=False
|
|
50
|
+
)
|
|
51
|
+
dd.trace_id = int(span.trace_id, 16)
|
|
52
|
+
dd.span_id = int(span.span_id, 16) & _DD_64BIT_MASK
|
|
53
|
+
if span.parent_id:
|
|
54
|
+
dd.parent_id = int(span.parent_id, 16) & _DD_64BIT_MASK
|
|
55
|
+
dd.start_ns = span.start_ns
|
|
56
|
+
dd.error = span.error
|
|
57
|
+
for key, tag in span.meta.items():
|
|
58
|
+
dd.set_tag(key, tag)
|
|
59
|
+
for key, metric in span.metrics.items():
|
|
60
|
+
dd.set_metric(key, metric)
|
|
61
|
+
dd.finish(finish_time=(span.start_ns + span.duration_ns) / _NANOS_PER_S)
|
|
62
|
+
|
|
63
|
+
def flush(self) -> bool:
|
|
64
|
+
flush = getattr(self._tracer, "flush", None)
|
|
65
|
+
if not callable(flush):
|
|
66
|
+
return True
|
|
67
|
+
try:
|
|
68
|
+
flush()
|
|
69
|
+
except Exception:
|
|
70
|
+
return False
|
|
71
|
+
return True
|
|
72
|
+
|
|
73
|
+
def stop(self) -> None:
|
|
74
|
+
shutdown = getattr(self._tracer, "shutdown", None)
|
|
75
|
+
if callable(shutdown):
|
|
76
|
+
shutdown()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class DogStatsdMetricSink: # pragma: no cover - requires a live DD Agent / dogstatsd
|
|
80
|
+
"""Emits DD metrics (cost / tokens) to dogstatsd via the DD Agent."""
|
|
81
|
+
|
|
82
|
+
def __init__(self, *, agent_endpoint: str | None) -> None:
|
|
83
|
+
dogstatsd_mod: Any = importlib.import_module("ddtrace.internal.dogstatsd")
|
|
84
|
+
host = "localhost"
|
|
85
|
+
if agent_endpoint:
|
|
86
|
+
host = agent_endpoint.split("://", 1)[-1].split(":", 1)[0]
|
|
87
|
+
self._client: Any = dogstatsd_mod.get_dogstatsd_client(f"udp://{host}:8125")
|
|
88
|
+
|
|
89
|
+
def emit(self, name: str, value: float, tags: Sequence[str]) -> None:
|
|
90
|
+
self._client.distribution(name, value, tags=list(tags))
|
|
91
|
+
|
|
92
|
+
def close(self) -> None:
|
|
93
|
+
close = getattr(self._client, "close", None)
|
|
94
|
+
if callable(close):
|
|
95
|
+
close()
|
|
@@ -0,0 +1,510 @@
|
|
|
1
|
+
"""``DatadogExporter`` — ForgeSight records → Datadog APM spans + DD metrics.
|
|
2
|
+
|
|
3
|
+
A :class:`~forgesight_api.TelemetryExporter` (so it resolves via the
|
|
4
|
+
``forgesight.exporters`` entry point and passes the conformance suite) that surfaces
|
|
5
|
+
agent telemetry in Datadog with the unified ``service`` / ``env`` / ``version`` tags, the
|
|
6
|
+
SDK's computed cost as the monitorable DD metric ``forgesight.cost_usd``, and LLM / tool /
|
|
7
|
+
MCP calls as child APM spans.
|
|
8
|
+
|
|
9
|
+
Two transports:
|
|
10
|
+
|
|
11
|
+
* ``"agent"`` (default) maps each record to a :class:`DatadogSpan` and hands it to a
|
|
12
|
+
:class:`DatadogSpanWriter` (a ``ddtrace`` writer to a local DD Agent by default), plus
|
|
13
|
+
emits cost/token DD metrics via a :class:`DatadogMetricSink`. The vendor-backed default
|
|
14
|
+
writer/sink are built lazily; tests inject doubles.
|
|
15
|
+
* ``"otlp"`` reuses ``forgesight-otel``'s :class:`~forgesight_otel.OTelExporter` pointed at
|
|
16
|
+
the DD Agent's OTLP port (or DD's OTLP intake), with the DD unified tags applied as
|
|
17
|
+
resource attributes Datadog reads.
|
|
18
|
+
|
|
19
|
+
``export`` never raises (P6): a DD Agent / intake outage returns ``ExportResult.FAILURE``,
|
|
20
|
+
counted by the pipeline, invisible to the agent. Content is attached only when
|
|
21
|
+
``capture_content`` is on (P7). Runs on the export worker, never the hot path.
|
|
22
|
+
|
|
23
|
+
**OTLP-native backends need no package.** Honeycomb / Jaeger / Tempo / SigNoz / New Relic /
|
|
24
|
+
X-Ray / Phoenix all ingest OTLP — point ``forgesight-otel`` at them (see
|
|
25
|
+
:data:`OTLP_NATIVE_BACKENDS`). Datadog earns a package only because its richest path is
|
|
26
|
+
DD-specific.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import logging
|
|
32
|
+
import os
|
|
33
|
+
from collections.abc import Mapping, Sequence
|
|
34
|
+
from dataclasses import dataclass
|
|
35
|
+
from types import MappingProxyType
|
|
36
|
+
from typing import Protocol, runtime_checkable
|
|
37
|
+
|
|
38
|
+
from forgesight_api import ExportResult, Kind, Record, RunStatus
|
|
39
|
+
|
|
40
|
+
_log = logging.getLogger("forgesight.datadog")
|
|
41
|
+
|
|
42
|
+
DEFAULT_SERVICE = "agentforge"
|
|
43
|
+
DEFAULT_SITE = "datadoghq.com"
|
|
44
|
+
DEFAULT_DD_AGENT_OTLP = "http://localhost:4317"
|
|
45
|
+
|
|
46
|
+
# Datadog sites a team may point at (architecture.md §4.5).
|
|
47
|
+
DD_SITES = frozenset(
|
|
48
|
+
{
|
|
49
|
+
"datadoghq.com",
|
|
50
|
+
"us3.datadoghq.com",
|
|
51
|
+
"us5.datadoghq.com",
|
|
52
|
+
"datadoghq.eu",
|
|
53
|
+
"ap1.datadoghq.com",
|
|
54
|
+
"ddog-gov.com",
|
|
55
|
+
}
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
# The keystone, stated once: these ingest OTLP and need NO dedicated package — point
|
|
59
|
+
# forgesight-otel at them. Datadog is the deliberate exception (its richest path is
|
|
60
|
+
# DD-specific), which is why this package exists.
|
|
61
|
+
OTLP_NATIVE_BACKENDS: Mapping[str, str] = MappingProxyType(
|
|
62
|
+
{
|
|
63
|
+
"honeycomb": "forgesight-otel -> api.honeycomb.io:443 + x-honeycomb-team header",
|
|
64
|
+
"jaeger": "forgesight-otel -> Jaeger OTLP :4317",
|
|
65
|
+
"tempo": "forgesight-otel -> Grafana Tempo OTLP endpoint",
|
|
66
|
+
"signoz": "forgesight-otel -> SigNoz OTLP collector",
|
|
67
|
+
"newrelic": "forgesight-otel -> otlp.nr-data.net:4317 + api-key header",
|
|
68
|
+
"xray": "forgesight-otel -> AWS Distro for OpenTelemetry (ADOT) collector",
|
|
69
|
+
"phoenix": "forgesight-otel -> Arize Phoenix OTLP endpoint",
|
|
70
|
+
}
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
_OP_INVOKE_AGENT = "invoke_agent"
|
|
74
|
+
_OP_INVOKE_WORKFLOW = "invoke_workflow"
|
|
75
|
+
_OP_CHAT = "chat"
|
|
76
|
+
_OP_EXECUTE_TOOL = "execute_tool"
|
|
77
|
+
_MCP_TOOLS_CALL = "tools/call"
|
|
78
|
+
|
|
79
|
+
_AGENT_VERSION_KEY = "agent.version"
|
|
80
|
+
_PARENT_RUN_ID_KEY = "parent.run_id"
|
|
81
|
+
_CONTEXT_ID_KEY = "context.id"
|
|
82
|
+
_STRUCTURED_KEYS = frozenset({_AGENT_VERSION_KEY, _PARENT_RUN_ID_KEY, _CONTEXT_ID_KEY})
|
|
83
|
+
|
|
84
|
+
_OK_STATUSES = frozenset({RunStatus.OK, RunStatus.RUNNING})
|
|
85
|
+
|
|
86
|
+
# DD APM operation names per kind (span.name; the detail goes in span.resource).
|
|
87
|
+
_DD_SPAN_NAME: Mapping[Kind, str] = {
|
|
88
|
+
Kind.AGENT: "forgesight.agent",
|
|
89
|
+
Kind.WORKFLOW: "forgesight.workflow",
|
|
90
|
+
Kind.STEP: "forgesight.step",
|
|
91
|
+
Kind.LLM: "forgesight.llm",
|
|
92
|
+
Kind.TOOL: "forgesight.tool",
|
|
93
|
+
Kind.MCP: "forgesight.mcp",
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
COST_METRIC = "forgesight.cost_usd"
|
|
97
|
+
TOKENS_METRIC = "forgesight.tokens"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass(frozen=True)
|
|
101
|
+
class DatadogSpan:
|
|
102
|
+
"""A backend-neutral DD APM span — the seam between mapping and the ``ddtrace`` writer."""
|
|
103
|
+
|
|
104
|
+
trace_id: str # W3C hex trace id (the writer narrows to DD's id space)
|
|
105
|
+
span_id: str
|
|
106
|
+
parent_id: str | None
|
|
107
|
+
name: str # DD operation name
|
|
108
|
+
resource: str # DD resource (agent_name / model / tool / method)
|
|
109
|
+
service: str
|
|
110
|
+
start_ns: int
|
|
111
|
+
duration_ns: int
|
|
112
|
+
error: int # 1 on a failed op, else 0
|
|
113
|
+
meta: dict[str, str] # string tags
|
|
114
|
+
metrics: dict[str, float] # numeric tags
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@runtime_checkable
|
|
118
|
+
class DatadogSpanWriter(Protocol):
|
|
119
|
+
"""Submits mapped spans to Datadog (``ddtrace`` writer → DD Agent by default)."""
|
|
120
|
+
|
|
121
|
+
def write(self, span: DatadogSpan) -> None: ...
|
|
122
|
+
|
|
123
|
+
def flush(self) -> bool: ...
|
|
124
|
+
|
|
125
|
+
def stop(self) -> None: ...
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@runtime_checkable
|
|
129
|
+
class DatadogMetricSink(Protocol):
|
|
130
|
+
"""Emits DD metrics (cost / tokens) — dogstatsd via the DD Agent by default."""
|
|
131
|
+
|
|
132
|
+
def emit(self, name: str, value: float, tags: Sequence[str]) -> None: ...
|
|
133
|
+
|
|
134
|
+
def close(self) -> None: ...
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@runtime_checkable
|
|
138
|
+
class _Sink(Protocol):
|
|
139
|
+
"""The transport-specific delivery surface DatadogExporter delegates to."""
|
|
140
|
+
|
|
141
|
+
def export(self, records: Sequence[Record]) -> ExportResult: ...
|
|
142
|
+
|
|
143
|
+
def force_flush(self, timeout_millis: int) -> bool: ...
|
|
144
|
+
|
|
145
|
+
def shutdown(self, timeout_millis: int) -> None: ...
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _env(*keys: str) -> str | None:
|
|
149
|
+
for key in keys:
|
|
150
|
+
value = os.environ.get(key)
|
|
151
|
+
if value:
|
|
152
|
+
return value
|
|
153
|
+
return None
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _env_bool(key: str, default: bool) -> bool:
|
|
157
|
+
raw = os.environ.get(key)
|
|
158
|
+
if raw is None:
|
|
159
|
+
return default
|
|
160
|
+
return raw.strip().lower() in ("1", "true", "yes", "on")
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class DatadogExporter:
|
|
164
|
+
"""Maps SDK records → Datadog APM spans + DD metrics (incl. cost). Stable from v0.2."""
|
|
165
|
+
|
|
166
|
+
def __init__(
|
|
167
|
+
self,
|
|
168
|
+
*,
|
|
169
|
+
api_key: str | None = None,
|
|
170
|
+
site: str = DEFAULT_SITE,
|
|
171
|
+
service: str = DEFAULT_SERVICE,
|
|
172
|
+
env: str | None = None,
|
|
173
|
+
version: str | None = None,
|
|
174
|
+
agent_endpoint: str | None = None,
|
|
175
|
+
transport: str = "agent",
|
|
176
|
+
capture_content: bool = False,
|
|
177
|
+
span_writer: DatadogSpanWriter | None = None,
|
|
178
|
+
metric_sink: DatadogMetricSink | None = None,
|
|
179
|
+
span_exporter: object | None = None,
|
|
180
|
+
) -> None:
|
|
181
|
+
self._api_key = (
|
|
182
|
+
api_key if api_key is not None else _env("DD_API_KEY", "FORGESIGHT_DATADOG_API_KEY")
|
|
183
|
+
)
|
|
184
|
+
self._site = (
|
|
185
|
+
site
|
|
186
|
+
if site != DEFAULT_SITE
|
|
187
|
+
else (_env("DD_SITE", "FORGESIGHT_DATADOG_SITE") or DEFAULT_SITE)
|
|
188
|
+
)
|
|
189
|
+
if self._site not in DD_SITES:
|
|
190
|
+
raise ValueError(
|
|
191
|
+
f"unknown Datadog site {self._site!r}; expected one of {sorted(DD_SITES)}"
|
|
192
|
+
)
|
|
193
|
+
self._service = (
|
|
194
|
+
service
|
|
195
|
+
if service != DEFAULT_SERVICE
|
|
196
|
+
else (_env("DD_SERVICE", "FORGESIGHT_DATADOG_SERVICE") or DEFAULT_SERVICE)
|
|
197
|
+
)
|
|
198
|
+
self._env = env if env is not None else _env("DD_ENV", "FORGESIGHT_DATADOG_ENV")
|
|
199
|
+
self._version = (
|
|
200
|
+
version if version is not None else _env("DD_VERSION", "FORGESIGHT_DATADOG_VERSION")
|
|
201
|
+
)
|
|
202
|
+
self._agent_endpoint = (
|
|
203
|
+
agent_endpoint
|
|
204
|
+
if agent_endpoint is not None
|
|
205
|
+
else _env("FORGESIGHT_DATADOG_AGENT_ENDPOINT")
|
|
206
|
+
)
|
|
207
|
+
self._transport = (
|
|
208
|
+
transport if transport != "agent" else (_env("FORGESIGHT_DATADOG_TRANSPORT") or "agent")
|
|
209
|
+
)
|
|
210
|
+
if self._transport not in ("agent", "otlp"):
|
|
211
|
+
raise ValueError(f"transport must be 'agent' or 'otlp', got {self._transport!r}")
|
|
212
|
+
self._capture_content = capture_content or _env_bool("FORGESIGHT_CAPTURE_CONTENT", False)
|
|
213
|
+
|
|
214
|
+
self._sink: _Sink = self._build_sink(span_writer, metric_sink, span_exporter)
|
|
215
|
+
|
|
216
|
+
# --- TelemetryExporter Protocol --------------------------------------
|
|
217
|
+
def export(self, records: Sequence[Record]) -> ExportResult:
|
|
218
|
+
return self._sink.export(records)
|
|
219
|
+
|
|
220
|
+
def force_flush(self, timeout_millis: int = 30_000) -> bool:
|
|
221
|
+
return self._sink.force_flush(timeout_millis)
|
|
222
|
+
|
|
223
|
+
def shutdown(self, timeout_millis: int = 30_000) -> None:
|
|
224
|
+
self._sink.shutdown(timeout_millis)
|
|
225
|
+
|
|
226
|
+
# --- transport wiring -------------------------------------------------
|
|
227
|
+
def _build_sink(
|
|
228
|
+
self,
|
|
229
|
+
span_writer: DatadogSpanWriter | None,
|
|
230
|
+
metric_sink: DatadogMetricSink | None,
|
|
231
|
+
span_exporter: object | None,
|
|
232
|
+
) -> _Sink:
|
|
233
|
+
if self._transport == "otlp":
|
|
234
|
+
if not self._agent_endpoint:
|
|
235
|
+
raise ValueError(
|
|
236
|
+
"transport='otlp' requires agent_endpoint "
|
|
237
|
+
"(DD Agent OTLP port or DD OTLP intake)"
|
|
238
|
+
)
|
|
239
|
+
return _OTLPSink(
|
|
240
|
+
endpoint=self._agent_endpoint,
|
|
241
|
+
service=self._service,
|
|
242
|
+
env=self._env,
|
|
243
|
+
version=self._version,
|
|
244
|
+
capture_content=self._capture_content,
|
|
245
|
+
span_exporter=span_exporter,
|
|
246
|
+
)
|
|
247
|
+
# transport == "agent"
|
|
248
|
+
if span_writer is None and not self._agent_endpoint and not self._api_key:
|
|
249
|
+
raise ValueError(
|
|
250
|
+
"transport='agent' direct intake requires api_key (or set agent_endpoint "
|
|
251
|
+
"for a local DD Agent)"
|
|
252
|
+
)
|
|
253
|
+
writer = span_writer if span_writer is not None else self._default_span_writer()
|
|
254
|
+
sink = metric_sink if metric_sink is not None else self._default_metric_sink()
|
|
255
|
+
return _AgentSink(
|
|
256
|
+
writer=writer,
|
|
257
|
+
metric_sink=sink,
|
|
258
|
+
service=self._service,
|
|
259
|
+
env=self._env,
|
|
260
|
+
version=self._version,
|
|
261
|
+
capture_content=self._capture_content,
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
def _default_span_writer(self) -> DatadogSpanWriter: # pragma: no cover - needs a live DD Agent
|
|
265
|
+
from ._ddtrace import DDTraceSpanWriter
|
|
266
|
+
|
|
267
|
+
return DDTraceSpanWriter(
|
|
268
|
+
service=self._service,
|
|
269
|
+
api_key=self._api_key,
|
|
270
|
+
site=self._site,
|
|
271
|
+
agent_endpoint=self._agent_endpoint,
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
def _default_metric_sink(self) -> DatadogMetricSink: # pragma: no cover - needs a live DD Agent
|
|
275
|
+
from ._ddtrace import DogStatsdMetricSink
|
|
276
|
+
|
|
277
|
+
return DogStatsdMetricSink(agent_endpoint=self._agent_endpoint)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
# --- record → DatadogSpan mapping (pure, fully tested) ----------------------
|
|
281
|
+
def _op(record: Record) -> str:
|
|
282
|
+
kind = record.kind
|
|
283
|
+
if kind is Kind.AGENT:
|
|
284
|
+
return _OP_INVOKE_AGENT
|
|
285
|
+
if kind is Kind.WORKFLOW:
|
|
286
|
+
return _OP_INVOKE_WORKFLOW
|
|
287
|
+
if kind is Kind.LLM:
|
|
288
|
+
return _OP_CHAT
|
|
289
|
+
if kind is Kind.TOOL:
|
|
290
|
+
return _OP_EXECUTE_TOOL
|
|
291
|
+
if kind is Kind.MCP and record.mcp is not None and record.mcp.method == _MCP_TOOLS_CALL:
|
|
292
|
+
return _OP_EXECUTE_TOOL
|
|
293
|
+
return ""
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _error_type(record: Record) -> str | None:
|
|
297
|
+
if record.error is not None:
|
|
298
|
+
return record.error.error_type
|
|
299
|
+
if record.status not in _OK_STATUSES:
|
|
300
|
+
return record.status.value
|
|
301
|
+
return None
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _resource(record: Record) -> str:
|
|
305
|
+
if record.kind is Kind.MCP and record.mcp is not None:
|
|
306
|
+
return record.mcp.method
|
|
307
|
+
return record.name
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def record_to_span(
|
|
311
|
+
record: Record, *, service: str, env: str | None, version: str | None, capture_content: bool
|
|
312
|
+
) -> DatadogSpan:
|
|
313
|
+
"""Map a Record onto a DD APM span with unified tags, gen_ai tags, and cost."""
|
|
314
|
+
attrs = record.attributes
|
|
315
|
+
meta: dict[str, str] = {"forgesight.run_id": record.run_id}
|
|
316
|
+
if env is not None:
|
|
317
|
+
meta["env"] = env
|
|
318
|
+
if version is not None:
|
|
319
|
+
meta["version"] = version
|
|
320
|
+
for key, value in attrs.items():
|
|
321
|
+
if key not in _STRUCTURED_KEYS:
|
|
322
|
+
meta[key] = str(value)
|
|
323
|
+
if _PARENT_RUN_ID_KEY in attrs:
|
|
324
|
+
meta["forgesight.parent_run_id"] = str(attrs[_PARENT_RUN_ID_KEY])
|
|
325
|
+
if _CONTEXT_ID_KEY in attrs:
|
|
326
|
+
meta["gen_ai.conversation.id"] = str(attrs[_CONTEXT_ID_KEY])
|
|
327
|
+
if _AGENT_VERSION_KEY in attrs:
|
|
328
|
+
meta["gen_ai.agent.version"] = str(attrs[_AGENT_VERSION_KEY])
|
|
329
|
+
|
|
330
|
+
op = _op(record)
|
|
331
|
+
if op:
|
|
332
|
+
meta["gen_ai.operation.name"] = op
|
|
333
|
+
if record.kind is Kind.AGENT:
|
|
334
|
+
meta["gen_ai.agent.name"] = record.name
|
|
335
|
+
|
|
336
|
+
metrics: dict[str, float] = {}
|
|
337
|
+
llm = record.llm
|
|
338
|
+
if llm is not None:
|
|
339
|
+
meta["gen_ai.provider.name"] = llm.provider
|
|
340
|
+
meta["gen_ai.request.model"] = llm.request_model
|
|
341
|
+
if llm.response_model is not None:
|
|
342
|
+
meta["gen_ai.response.model"] = llm.response_model
|
|
343
|
+
usage = llm.usage
|
|
344
|
+
for tag, value in (
|
|
345
|
+
("input_tokens", usage.input),
|
|
346
|
+
("output_tokens", usage.output),
|
|
347
|
+
("cache_read_tokens", usage.cache_read),
|
|
348
|
+
("cache_creation_tokens", usage.cache_creation),
|
|
349
|
+
("reasoning_tokens", usage.reasoning),
|
|
350
|
+
):
|
|
351
|
+
if value:
|
|
352
|
+
metrics[f"gen_ai.usage.{tag}"] = float(value)
|
|
353
|
+
if llm.cost_usd is not None:
|
|
354
|
+
metrics[COST_METRIC] = llm.cost_usd
|
|
355
|
+
meta[COST_METRIC] = f"{llm.cost_usd:.6f}" # also a span tag (monitorable)
|
|
356
|
+
if capture_content and llm.content is not None:
|
|
357
|
+
_attach_content(llm.content, meta)
|
|
358
|
+
if record.tool is not None:
|
|
359
|
+
meta["gen_ai.tool.name"] = record.tool.name
|
|
360
|
+
meta["gen_ai.tool.type"] = record.tool.tool_type
|
|
361
|
+
if record.mcp is not None:
|
|
362
|
+
meta["mcp.method.name"] = record.mcp.method
|
|
363
|
+
meta["mcp.server"] = record.mcp.server
|
|
364
|
+
if record.mcp.tool is not None:
|
|
365
|
+
meta["gen_ai.tool.name"] = record.mcp.tool
|
|
366
|
+
|
|
367
|
+
error_type = _error_type(record)
|
|
368
|
+
if error_type is not None:
|
|
369
|
+
meta["error.type"] = error_type
|
|
370
|
+
if record.error is not None:
|
|
371
|
+
meta["error.message"] = record.error.message
|
|
372
|
+
|
|
373
|
+
end = record.end_unix_nanos if record.end_unix_nanos is not None else record.start_unix_nanos
|
|
374
|
+
return DatadogSpan(
|
|
375
|
+
trace_id=record.trace_id,
|
|
376
|
+
span_id=record.span_id,
|
|
377
|
+
parent_id=record.parent_span_id,
|
|
378
|
+
name=_DD_SPAN_NAME[record.kind],
|
|
379
|
+
resource=_resource(record),
|
|
380
|
+
service=service,
|
|
381
|
+
start_ns=record.start_unix_nanos,
|
|
382
|
+
duration_ns=max(0, end - record.start_unix_nanos),
|
|
383
|
+
error=0 if record.status in _OK_STATUSES else 1,
|
|
384
|
+
meta=meta,
|
|
385
|
+
metrics=metrics,
|
|
386
|
+
)
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _attach_content(content: object, meta: dict[str, str]) -> None:
|
|
390
|
+
import json
|
|
391
|
+
|
|
392
|
+
for attr, key in (
|
|
393
|
+
("input_messages", "gen_ai.input.messages"),
|
|
394
|
+
("output_messages", "gen_ai.output.messages"),
|
|
395
|
+
("system_instructions", "gen_ai.system_instructions"),
|
|
396
|
+
):
|
|
397
|
+
value = getattr(content, attr, None)
|
|
398
|
+
if value is not None:
|
|
399
|
+
meta[key] = json.dumps(value, default=str)
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
# --- transports -------------------------------------------------------------
|
|
403
|
+
class _AgentSink:
|
|
404
|
+
"""DD Agent transport: ddtrace span writer + dogstatsd cost/token metrics."""
|
|
405
|
+
|
|
406
|
+
def __init__(
|
|
407
|
+
self,
|
|
408
|
+
*,
|
|
409
|
+
writer: DatadogSpanWriter,
|
|
410
|
+
metric_sink: DatadogMetricSink,
|
|
411
|
+
service: str,
|
|
412
|
+
env: str | None,
|
|
413
|
+
version: str | None,
|
|
414
|
+
capture_content: bool,
|
|
415
|
+
) -> None:
|
|
416
|
+
self._writer = writer
|
|
417
|
+
self._metrics = metric_sink
|
|
418
|
+
self._service = service
|
|
419
|
+
self._env = env
|
|
420
|
+
self._version = version
|
|
421
|
+
self._capture_content = capture_content
|
|
422
|
+
|
|
423
|
+
def export(self, records: Sequence[Record]) -> ExportResult:
|
|
424
|
+
try:
|
|
425
|
+
for record in records:
|
|
426
|
+
span = record_to_span(
|
|
427
|
+
record,
|
|
428
|
+
service=self._service,
|
|
429
|
+
env=self._env,
|
|
430
|
+
version=self._version,
|
|
431
|
+
capture_content=self._capture_content,
|
|
432
|
+
)
|
|
433
|
+
self._writer.write(span)
|
|
434
|
+
self._emit_metrics(record, span)
|
|
435
|
+
except Exception: # a DD Agent outage is counted, never raised (P6)
|
|
436
|
+
_log.warning("datadog agent export failed", exc_info=True)
|
|
437
|
+
return ExportResult.FAILURE
|
|
438
|
+
return ExportResult.SUCCESS
|
|
439
|
+
|
|
440
|
+
def _emit_metrics(self, record: Record, span: DatadogSpan) -> None:
|
|
441
|
+
llm = record.llm
|
|
442
|
+
if llm is None:
|
|
443
|
+
return
|
|
444
|
+
base = [f"service:{self._service}"]
|
|
445
|
+
if self._env is not None:
|
|
446
|
+
base.append(f"env:{self._env}")
|
|
447
|
+
model_tags = [*base, f"provider:{llm.provider}", f"model:{llm.request_model}"]
|
|
448
|
+
if llm.cost_usd is not None:
|
|
449
|
+
self._metrics.emit(COST_METRIC, llm.cost_usd, model_tags)
|
|
450
|
+
for token_type, value in (
|
|
451
|
+
("input", llm.usage.input),
|
|
452
|
+
("output", llm.usage.output),
|
|
453
|
+
("cache_read", llm.usage.cache_read),
|
|
454
|
+
("cache_creation", llm.usage.cache_creation),
|
|
455
|
+
("reasoning", llm.usage.reasoning),
|
|
456
|
+
):
|
|
457
|
+
if value:
|
|
458
|
+
self._metrics.emit(
|
|
459
|
+
TOKENS_METRIC, float(value), [*model_tags, f"gen_ai_token_type:{token_type}"]
|
|
460
|
+
)
|
|
461
|
+
|
|
462
|
+
def force_flush(self, timeout_millis: int) -> bool:
|
|
463
|
+
return self._writer.flush()
|
|
464
|
+
|
|
465
|
+
def shutdown(self, timeout_millis: int) -> None:
|
|
466
|
+
try:
|
|
467
|
+
self._writer.stop()
|
|
468
|
+
finally:
|
|
469
|
+
self._metrics.close()
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
class _OTLPSink:
|
|
473
|
+
"""OTLP transport: forgesight-otel → DD Agent OTLP port, with DD unified tags."""
|
|
474
|
+
|
|
475
|
+
def __init__(
|
|
476
|
+
self,
|
|
477
|
+
*,
|
|
478
|
+
endpoint: str,
|
|
479
|
+
service: str,
|
|
480
|
+
env: str | None,
|
|
481
|
+
version: str | None,
|
|
482
|
+
capture_content: bool,
|
|
483
|
+
span_exporter: object | None,
|
|
484
|
+
) -> None:
|
|
485
|
+
from forgesight_otel import OTelExporter
|
|
486
|
+
|
|
487
|
+
resource: dict[str, str] = {}
|
|
488
|
+
if env is not None:
|
|
489
|
+
resource["deployment.environment"] = env # DD reads this as `env`
|
|
490
|
+
if version is not None:
|
|
491
|
+
resource["service.version"] = version # DD reads this as `version`
|
|
492
|
+
# http/protobuf needs no optional [grpc] extra; the DD Agent's OTLP/HTTP port is
|
|
493
|
+
# :4318. (forgesight-otel can still do grpc if the extra is installed.)
|
|
494
|
+
self._otel = OTelExporter(
|
|
495
|
+
endpoint=endpoint,
|
|
496
|
+
protocol="http/protobuf",
|
|
497
|
+
service_name=service,
|
|
498
|
+
capture_content=capture_content,
|
|
499
|
+
resource_attributes=resource or None,
|
|
500
|
+
span_exporter=span_exporter, # type: ignore[arg-type]
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
def export(self, records: Sequence[Record]) -> ExportResult:
|
|
504
|
+
return self._otel.export(records)
|
|
505
|
+
|
|
506
|
+
def force_flush(self, timeout_millis: int) -> bool:
|
|
507
|
+
return self._otel.force_flush(timeout_millis)
|
|
508
|
+
|
|
509
|
+
def shutdown(self, timeout_millis: int) -> None:
|
|
510
|
+
self._otel.shutdown(timeout_millis)
|
|
File without changes
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"""Doubles for testing the Datadog exporter without a live DD Agent.
|
|
2
|
+
|
|
3
|
+
:class:`InMemoryDatadogSpanWriter` and :class:`InMemoryDatadogMetricSink` satisfy the
|
|
4
|
+
``DatadogSpanWriter`` / ``DatadogMetricSink`` protocols and record everything written, so a
|
|
5
|
+
test (or a consuming team's pipeline test) can assert the mapped spans, unified tags, and
|
|
6
|
+
the cost / token DD metrics.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections.abc import Sequence
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
|
|
14
|
+
from .exporter import DatadogSpan
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class InMemoryDatadogSpanWriter:
|
|
18
|
+
"""Captures every mapped :class:`DatadogSpan` instead of writing to a DD Agent."""
|
|
19
|
+
|
|
20
|
+
def __init__(self) -> None:
|
|
21
|
+
self.spans: list[DatadogSpan] = []
|
|
22
|
+
self.flushed = 0
|
|
23
|
+
self.stopped = False
|
|
24
|
+
|
|
25
|
+
def write(self, span: DatadogSpan) -> None:
|
|
26
|
+
self.spans.append(span)
|
|
27
|
+
|
|
28
|
+
def flush(self) -> bool:
|
|
29
|
+
self.flushed += 1
|
|
30
|
+
return True
|
|
31
|
+
|
|
32
|
+
def stop(self) -> None:
|
|
33
|
+
self.stopped = True
|
|
34
|
+
|
|
35
|
+
def by_resource(self) -> dict[str, DatadogSpan]:
|
|
36
|
+
return {span.resource: span for span in self.spans}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class MetricCall:
|
|
41
|
+
"""One emitted DD metric."""
|
|
42
|
+
|
|
43
|
+
name: str
|
|
44
|
+
value: float
|
|
45
|
+
tags: list[str] = field(default_factory=list)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class InMemoryDatadogMetricSink:
|
|
49
|
+
"""Captures every emitted DD metric instead of sending to dogstatsd."""
|
|
50
|
+
|
|
51
|
+
def __init__(self) -> None:
|
|
52
|
+
self.metrics: list[MetricCall] = []
|
|
53
|
+
self.closed = False
|
|
54
|
+
|
|
55
|
+
def emit(self, name: str, value: float, tags: Sequence[str]) -> None:
|
|
56
|
+
self.metrics.append(MetricCall(name=name, value=value, tags=list(tags)))
|
|
57
|
+
|
|
58
|
+
def close(self) -> None:
|
|
59
|
+
self.closed = True
|
|
60
|
+
|
|
61
|
+
def named(self, name: str) -> list[MetricCall]:
|
|
62
|
+
return [m for m in self.metrics if m.name == name]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
__all__ = [
|
|
66
|
+
"InMemoryDatadogMetricSink",
|
|
67
|
+
"InMemoryDatadogSpanWriter",
|
|
68
|
+
"MetricCall",
|
|
69
|
+
]
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: forgesight-datadog
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: ForgeSight Datadog exporter — DD-native APM spans + cost metric, via DD Agent or OTLP intake.
|
|
5
|
+
Project-URL: Homepage, https://github.com/Scaffoldic/forgesight
|
|
6
|
+
Project-URL: Repository, https://github.com/Scaffoldic/forgesight
|
|
7
|
+
Project-URL: Issues, https://github.com/Scaffoldic/forgesight/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/Scaffoldic/forgesight/blob/main/docs/releases/v0.1.md
|
|
9
|
+
Author: kjoshi
|
|
10
|
+
License-Expression: Apache-2.0
|
|
11
|
+
Keywords: ai-agents,apm,datadog,ddtrace,forgesight,observability
|
|
12
|
+
Classifier: Development Status :: 2 - Pre-Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Intended Audience :: Information Technology
|
|
15
|
+
Classifier: License :: OSI Approved :: Apache Software License
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
19
|
+
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
20
|
+
Classifier: Topic :: System :: Monitoring
|
|
21
|
+
Classifier: Typing :: Typed
|
|
22
|
+
Requires-Python: >=3.11
|
|
23
|
+
Requires-Dist: ddtrace>=2
|
|
24
|
+
Requires-Dist: forgesight-core
|
|
25
|
+
Requires-Dist: forgesight-otel
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# forgesight-datadog
|
|
29
|
+
|
|
30
|
+
The Datadog exporter for [ForgeSight](https://github.com/Scaffoldic/forgesight).
|
|
31
|
+
Surfaces agent telemetry in **Datadog APM** with the unified `service` / `env` / `version`
|
|
32
|
+
tags, LLM / tool / MCP calls as child spans, and the SDK's **computed cost** as the
|
|
33
|
+
monitorable DD metric `forgesight.cost_usd` — the same number every other backend reports.
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
pip install forgesight-datadog
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
import forgesight
|
|
41
|
+
from forgesight_datadog import DatadogExporter
|
|
42
|
+
|
|
43
|
+
forgesight.configure(exporters=[
|
|
44
|
+
DatadogExporter(api_key="...", site="datadoghq.com",
|
|
45
|
+
service="issue-classifier", env="prod"),
|
|
46
|
+
])
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Or by name: `exporters: [{name: datadog, config: {api_key: "${DD_API_KEY}", service: ...}}]`.
|
|
50
|
+
|
|
51
|
+
## Two transports
|
|
52
|
+
|
|
53
|
+
- **`agent`** (default) — maps each record to a DD APM span via `ddtrace` and writes it to a
|
|
54
|
+
local DD Agent (`agent_endpoint: http://datadog-agent:8126`), plus emits cost/token DD
|
|
55
|
+
metrics. Direct intake (no `agent_endpoint`) requires `api_key`.
|
|
56
|
+
- **`otlp`** — sends OTLP/HTTP to the DD Agent's OTLP port (`agent_endpoint: http://datadog-agent:4318`)
|
|
57
|
+
with the DD unified tags applied as resource attributes. Reuses `forgesight-otel`.
|
|
58
|
+
|
|
59
|
+
A DD Agent / intake outage makes `export()` return `FAILURE` (counted, never raised — P6);
|
|
60
|
+
it never blocks the agent. Prompt/response content is attached only with
|
|
61
|
+
`capture_content=True` (off by default, P7).
|
|
62
|
+
|
|
63
|
+
## OTLP-native backends need **no package**
|
|
64
|
+
|
|
65
|
+
Because the domain model maps cleanly onto the OTel GenAI conventions, anything that ingests
|
|
66
|
+
OTLP works through `forgesight-otel` with **no dedicated package** — point it at the backend
|
|
67
|
+
and you're done:
|
|
68
|
+
|
|
69
|
+
| Backend | How to send |
|
|
70
|
+
|---|---|
|
|
71
|
+
| Honeycomb | `forgesight-otel` → `api.honeycomb.io:443` + `x-honeycomb-team` header |
|
|
72
|
+
| Jaeger / Tempo / SigNoz | `forgesight-otel` → its OTLP collector |
|
|
73
|
+
| New Relic | `forgesight-otel` → `otlp.nr-data.net:4317` + `api-key` header |
|
|
74
|
+
| AWS X-Ray | `forgesight-otel` → ADOT collector |
|
|
75
|
+
| Arize Phoenix | `forgesight-otel` → Phoenix OTLP endpoint |
|
|
76
|
+
|
|
77
|
+
Datadog earns a package **only** because its richest path (DD-native APM tagging +
|
|
78
|
+
cost-as-DD-metric) is DD-specific. A team that only wants generic spans in Datadog can use
|
|
79
|
+
the OTLP path and skip this package entirely.
|
|
80
|
+
|
|
81
|
+
## Configuration
|
|
82
|
+
|
|
83
|
+
| Key | Env | Default |
|
|
84
|
+
|---|---|---|
|
|
85
|
+
| `api_key` | `DD_API_KEY` / `FORGESIGHT_DATADOG_API_KEY` | — (required for direct intake) |
|
|
86
|
+
| `site` | `DD_SITE` / `FORGESIGHT_DATADOG_SITE` | `datadoghq.com` |
|
|
87
|
+
| `service` | `DD_SERVICE` / `FORGESIGHT_DATADOG_SERVICE` | `agentforge` |
|
|
88
|
+
| `env` | `DD_ENV` / `FORGESIGHT_DATADOG_ENV` | — |
|
|
89
|
+
| `version` | `DD_VERSION` / `FORGESIGHT_DATADOG_VERSION` | — |
|
|
90
|
+
| `agent_endpoint` | `FORGESIGHT_DATADOG_AGENT_ENDPOINT` | — |
|
|
91
|
+
| `transport` | `FORGESIGHT_DATADOG_TRANSPORT` | `agent` |
|
|
92
|
+
|
|
93
|
+
Constructor kwargs win over env (FR-12).
|
|
94
|
+
|
|
95
|
+
## License
|
|
96
|
+
|
|
97
|
+
Apache-2.0
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
forgesight_datadog/__init__.py,sha256=CSJ6vowkefjicTeZzttc1La32UAX6w6YkjJIQSvP9w8,760
|
|
2
|
+
forgesight_datadog/_ddtrace.py,sha256=JqXssLQpIel060ZsckcMNjhSMrjhH2zHts9Xg3N-1Jg,3563
|
|
3
|
+
forgesight_datadog/exporter.py,sha256=c-NGvV6Ju2HUaJ4OzgiI1I-2Qe7eNK5GSeqJmpHfnwQ,18528
|
|
4
|
+
forgesight_datadog/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
forgesight_datadog/testing.py,sha256=-7CZjY_pA3wMy4eJesHmylpYGCoHC70WBUBhIYcbqtQ,1901
|
|
6
|
+
forgesight_datadog-0.1.0.dist-info/METADATA,sha256=LfA2BjyASV2dEnLeBO-gR4dMCy4EVzY1Dk0p3OoMyGg,4125
|
|
7
|
+
forgesight_datadog-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
8
|
+
forgesight_datadog-0.1.0.dist-info/entry_points.txt,sha256=YIpk7x-mXg8VHVkv14jSbI631Lih3hS7VK93hKToz_8,77
|
|
9
|
+
forgesight_datadog-0.1.0.dist-info/RECORD,,
|