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.
@@ -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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [forgesight.exporters]
2
+ datadog = forgesight_datadog.exporter:DatadogExporter