traceiq-capture 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.
- traceiq/__init__.py +17 -0
- traceiq/_compat.py +113 -0
- traceiq/_context.py +120 -0
- traceiq/_enrich.py +168 -0
- traceiq/_exporter.py +220 -0
- traceiq/_ids.py +10 -0
- traceiq/_init.py +167 -0
- traceiq/_modality.py +32 -0
- traceiq/_step.py +27 -0
- traceiq/decorators.py +147 -0
- traceiq/py.typed +0 -0
- traceiq_capture-0.1.0.dist-info/METADATA +129 -0
- traceiq_capture-0.1.0.dist-info/RECORD +15 -0
- traceiq_capture-0.1.0.dist-info/WHEEL +4 -0
- traceiq_capture-0.1.0.dist-info/licenses/LICENSE +21 -0
traceiq/__init__.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""TraceIQ Capture — OpenTelemetry instrumentation with a locked attribute contract."""
|
|
2
|
+
|
|
3
|
+
from traceiq._context import clear_context, set_context
|
|
4
|
+
from traceiq._init import flush, init
|
|
5
|
+
from traceiq.decorators import agent, task, tool, workflow
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"init",
|
|
9
|
+
"flush",
|
|
10
|
+
"set_context",
|
|
11
|
+
"clear_context",
|
|
12
|
+
"workflow",
|
|
13
|
+
"task",
|
|
14
|
+
"tool",
|
|
15
|
+
"agent",
|
|
16
|
+
]
|
|
17
|
+
__version__ = "0.1.0"
|
traceiq/_compat.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Normalize gen_ai.* token/model/provider attrs (read-only fallbacks)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Mapping, MutableMapping
|
|
6
|
+
|
|
7
|
+
ATTR_SYSTEM = "gen_ai.system"
|
|
8
|
+
ATTR_REQUEST_MODEL = "gen_ai.request.model"
|
|
9
|
+
ATTR_RESPONSE_MODEL = "gen_ai.response.model"
|
|
10
|
+
ATTR_INPUT_TOKENS = "gen_ai.usage.input_tokens"
|
|
11
|
+
ATTR_OUTPUT_TOKENS = "gen_ai.usage.output_tokens"
|
|
12
|
+
ATTR_PROMPT_TOKENS = "gen_ai.usage.prompt_tokens"
|
|
13
|
+
ATTR_COMPLETION_TOKENS = "gen_ai.usage.completion_tokens"
|
|
14
|
+
ATTR_PROVIDER_NAME = "gen_ai.provider.name"
|
|
15
|
+
|
|
16
|
+
# Cache key variants seen in the wild
|
|
17
|
+
CACHE_READ_CANDIDATES = (
|
|
18
|
+
"gen_ai.usage.cache_read_input_tokens",
|
|
19
|
+
"gen_ai.usage.cache_read_tokens",
|
|
20
|
+
"llm.usage.cache_read_input_tokens",
|
|
21
|
+
)
|
|
22
|
+
CACHE_WRITE_CANDIDATES = (
|
|
23
|
+
"gen_ai.usage.cache_creation_input_tokens",
|
|
24
|
+
"gen_ai.usage.cache_write_tokens",
|
|
25
|
+
"llm.usage.cache_creation_input_tokens",
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
_MODEL_PREFIX_TO_SYSTEM = (
|
|
29
|
+
(("gpt-", "o1", "o3", "text-embedding", "whisper", "tts-"), "openai"),
|
|
30
|
+
(("claude-",), "anthropic"),
|
|
31
|
+
(("gemini-",), "google"),
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _get(attrs: Mapping[str, Any], *keys: str) -> Any:
|
|
36
|
+
for key in keys:
|
|
37
|
+
if key in attrs and attrs[key] is not None:
|
|
38
|
+
return attrs[key]
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def normalize_tokens(attrs: MutableMapping[str, Any]) -> None:
|
|
43
|
+
"""prompt/completion → input/output tokens."""
|
|
44
|
+
if ATTR_INPUT_TOKENS not in attrs:
|
|
45
|
+
legacy = _get(attrs, ATTR_PROMPT_TOKENS, "llm.usage.prompt_tokens")
|
|
46
|
+
if legacy is not None:
|
|
47
|
+
attrs[ATTR_INPUT_TOKENS] = int(legacy)
|
|
48
|
+
if ATTR_OUTPUT_TOKENS not in attrs:
|
|
49
|
+
legacy = _get(attrs, ATTR_COMPLETION_TOKENS, "llm.usage.completion_tokens")
|
|
50
|
+
if legacy is not None:
|
|
51
|
+
attrs[ATTR_OUTPUT_TOKENS] = int(legacy)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def ensure_model(attrs: MutableMapping[str, Any]) -> None:
|
|
55
|
+
if ATTR_REQUEST_MODEL not in attrs or not attrs[ATTR_REQUEST_MODEL]:
|
|
56
|
+
resp = _get(attrs, ATTR_RESPONSE_MODEL, "llm.request.model", "llm.model_name")
|
|
57
|
+
if resp:
|
|
58
|
+
attrs[ATTR_REQUEST_MODEL] = str(resp)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def ensure_provider(attrs: MutableMapping[str, Any]) -> None:
|
|
62
|
+
if attrs.get(ATTR_SYSTEM):
|
|
63
|
+
return
|
|
64
|
+
provider = _get(attrs, ATTR_PROVIDER_NAME)
|
|
65
|
+
if provider:
|
|
66
|
+
attrs[ATTR_SYSTEM] = str(provider)
|
|
67
|
+
return
|
|
68
|
+
model = str(attrs.get(ATTR_REQUEST_MODEL) or "")
|
|
69
|
+
lower = model.lower()
|
|
70
|
+
for prefixes, system in _MODEL_PREFIX_TO_SYSTEM:
|
|
71
|
+
if any(lower.startswith(p) for p in prefixes):
|
|
72
|
+
attrs[ATTR_SYSTEM] = system
|
|
73
|
+
return
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def extract_cache_usage(attrs: Mapping[str, Any]) -> dict[str, int]:
|
|
77
|
+
out: dict[str, int] = {}
|
|
78
|
+
for key in CACHE_READ_CANDIDATES:
|
|
79
|
+
if key in attrs and attrs[key] is not None:
|
|
80
|
+
out["traceiq.usage.cache_read_tokens"] = int(attrs[key])
|
|
81
|
+
break
|
|
82
|
+
for key in CACHE_WRITE_CANDIDATES:
|
|
83
|
+
if key in attrs and attrs[key] is not None:
|
|
84
|
+
out["traceiq.usage.cache_write_tokens"] = int(attrs[key])
|
|
85
|
+
break
|
|
86
|
+
return out
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def extract_audio_vision_usage(attrs: Mapping[str, Any]) -> dict[str, int]:
|
|
90
|
+
out: dict[str, int] = {}
|
|
91
|
+
audio_in = _get(
|
|
92
|
+
attrs,
|
|
93
|
+
"gen_ai.usage.input_audio_tokens",
|
|
94
|
+
"traceiq.usage.audio_input_tokens",
|
|
95
|
+
)
|
|
96
|
+
audio_out = _get(
|
|
97
|
+
attrs,
|
|
98
|
+
"gen_ai.usage.output_audio_tokens",
|
|
99
|
+
"traceiq.usage.audio_output_tokens",
|
|
100
|
+
)
|
|
101
|
+
vision = _get(
|
|
102
|
+
attrs,
|
|
103
|
+
"gen_ai.usage.image_tokens",
|
|
104
|
+
"llm.usage.image_tokens",
|
|
105
|
+
"traceiq.usage.vision_tokens",
|
|
106
|
+
)
|
|
107
|
+
if audio_in is not None:
|
|
108
|
+
out["traceiq.usage.audio_input_tokens"] = int(audio_in)
|
|
109
|
+
if audio_out is not None:
|
|
110
|
+
out["traceiq.usage.audio_output_tokens"] = int(audio_out)
|
|
111
|
+
if vision is not None:
|
|
112
|
+
out["traceiq.usage.vision_tokens"] = int(vision)
|
|
113
|
+
return out
|
traceiq/_context.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""Process globals + per-request ContextVar (locked §3.1 fields only)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from contextvars import ContextVar
|
|
6
|
+
from dataclasses import dataclass, fields
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
INGEST_SOURCE = "sdk"
|
|
10
|
+
|
|
11
|
+
# Allowlisted ContextVar keys (set_context)
|
|
12
|
+
_CONTEXT_KEYS = frozenset(
|
|
13
|
+
{
|
|
14
|
+
"workflow_id",
|
|
15
|
+
"agent_name",
|
|
16
|
+
"product_id",
|
|
17
|
+
"product_name",
|
|
18
|
+
"project_id",
|
|
19
|
+
"project_name",
|
|
20
|
+
}
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass
|
|
25
|
+
class _Globals:
|
|
26
|
+
app_name: str = ""
|
|
27
|
+
environment: str = ""
|
|
28
|
+
ingest_source: str = INGEST_SOURCE
|
|
29
|
+
api_key: str = ""
|
|
30
|
+
endpoint: str = ""
|
|
31
|
+
export_mode: str = "otlp"
|
|
32
|
+
disable_batch: bool = False
|
|
33
|
+
initialized: bool = False
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class _RequestContext:
|
|
38
|
+
workflow_id: str = ""
|
|
39
|
+
agent_name: str = ""
|
|
40
|
+
product_id: str = ""
|
|
41
|
+
product_name: str = ""
|
|
42
|
+
project_id: str = ""
|
|
43
|
+
project_name: str = ""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
_globals = _Globals()
|
|
47
|
+
_request_ctx: ContextVar[_RequestContext] = ContextVar(
|
|
48
|
+
"traceiq_request_ctx", default=_RequestContext()
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def configure_globals(
|
|
53
|
+
*,
|
|
54
|
+
app_name: str = "",
|
|
55
|
+
environment: str = "",
|
|
56
|
+
api_key: str = "",
|
|
57
|
+
endpoint: str = "",
|
|
58
|
+
export_mode: str = "otlp",
|
|
59
|
+
disable_batch: bool = False,
|
|
60
|
+
) -> None:
|
|
61
|
+
_globals.app_name = app_name or ""
|
|
62
|
+
_globals.environment = environment or ""
|
|
63
|
+
_globals.ingest_source = INGEST_SOURCE
|
|
64
|
+
_globals.api_key = api_key or ""
|
|
65
|
+
_globals.endpoint = (endpoint or "").rstrip("/")
|
|
66
|
+
_globals.export_mode = export_mode or "otlp"
|
|
67
|
+
_globals.disable_batch = bool(disable_batch)
|
|
68
|
+
_globals.initialized = True
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def get_globals() -> _Globals:
|
|
72
|
+
return _globals
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def set_context(**kwargs: Any) -> None:
|
|
76
|
+
"""Per-request dims. Allowed: workflow_id, agent_name, product_*, project_*."""
|
|
77
|
+
unknown = set(kwargs) - _CONTEXT_KEYS
|
|
78
|
+
if unknown:
|
|
79
|
+
raise TypeError(f"Unknown set_context fields: {sorted(unknown)}")
|
|
80
|
+
current = _request_ctx.get()
|
|
81
|
+
data = {f.name: getattr(current, f.name) for f in fields(current)}
|
|
82
|
+
for key, value in kwargs.items():
|
|
83
|
+
if value is None:
|
|
84
|
+
continue
|
|
85
|
+
data[key] = str(value)
|
|
86
|
+
_request_ctx.set(_RequestContext(**data))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def clear_context() -> None:
|
|
90
|
+
_request_ctx.set(_RequestContext())
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def get_request_context() -> _RequestContext:
|
|
94
|
+
return _request_ctx.get()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def merged_trace_attrs() -> dict[str, str]:
|
|
98
|
+
"""Globals + request context for stamping on every span (§3.1)."""
|
|
99
|
+
g = _globals
|
|
100
|
+
r = _request_ctx.get()
|
|
101
|
+
out: dict[str, str] = {
|
|
102
|
+
"traceiq.ingest_source": INGEST_SOURCE,
|
|
103
|
+
}
|
|
104
|
+
if g.app_name:
|
|
105
|
+
out["traceiq.app_name"] = g.app_name
|
|
106
|
+
if g.environment:
|
|
107
|
+
out["traceiq.environment"] = g.environment
|
|
108
|
+
if r.workflow_id:
|
|
109
|
+
out["traceiq.workflow_id"] = r.workflow_id
|
|
110
|
+
if r.agent_name:
|
|
111
|
+
out["traceiq.agent_name"] = r.agent_name
|
|
112
|
+
if r.product_id:
|
|
113
|
+
out["traceiq.product.id"] = r.product_id
|
|
114
|
+
if r.product_name:
|
|
115
|
+
out["traceiq.product.name"] = r.product_name
|
|
116
|
+
if r.project_id:
|
|
117
|
+
out["traceiq.project.id"] = r.project_id
|
|
118
|
+
if r.project_name:
|
|
119
|
+
out["traceiq.project.name"] = r.project_name
|
|
120
|
+
return out
|
traceiq/_enrich.py
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Lean enrichment — stamp §3 attrs; allowlist contract."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Sequence
|
|
6
|
+
|
|
7
|
+
from opentelemetry.context import Context
|
|
8
|
+
from opentelemetry.sdk.trace import ReadableSpan, Span, SpanProcessor
|
|
9
|
+
from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult
|
|
10
|
+
|
|
11
|
+
from traceiq._compat import (
|
|
12
|
+
ensure_model,
|
|
13
|
+
ensure_provider,
|
|
14
|
+
extract_audio_vision_usage,
|
|
15
|
+
extract_cache_usage,
|
|
16
|
+
normalize_tokens,
|
|
17
|
+
)
|
|
18
|
+
from traceiq._context import merged_trace_attrs
|
|
19
|
+
from traceiq._modality import ATTR_MODALITY, infer_modality
|
|
20
|
+
from traceiq._step import ATTR_STEP_TYPE, infer_step_type
|
|
21
|
+
|
|
22
|
+
TRACEIQ_ALLOWLIST = frozenset(
|
|
23
|
+
{
|
|
24
|
+
"traceiq.workflow_id",
|
|
25
|
+
"traceiq.environment",
|
|
26
|
+
"traceiq.app_name",
|
|
27
|
+
"traceiq.agent_name",
|
|
28
|
+
"traceiq.product.id",
|
|
29
|
+
"traceiq.product.name",
|
|
30
|
+
"traceiq.project.id",
|
|
31
|
+
"traceiq.project.name",
|
|
32
|
+
"traceiq.ingest_source",
|
|
33
|
+
"traceiq.step_type",
|
|
34
|
+
"traceiq.modality",
|
|
35
|
+
"traceiq.trace.root",
|
|
36
|
+
"traceiq.usage.cache_read_tokens",
|
|
37
|
+
"traceiq.usage.cache_write_tokens",
|
|
38
|
+
"traceiq.usage.audio_input_tokens",
|
|
39
|
+
"traceiq.usage.audio_output_tokens",
|
|
40
|
+
"traceiq.usage.vision_tokens",
|
|
41
|
+
}
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
GEN_AI_ALLOWLIST = frozenset(
|
|
45
|
+
{
|
|
46
|
+
"gen_ai.system",
|
|
47
|
+
"gen_ai.request.model",
|
|
48
|
+
"gen_ai.response.model",
|
|
49
|
+
"gen_ai.usage.input_tokens",
|
|
50
|
+
"gen_ai.usage.output_tokens",
|
|
51
|
+
}
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def enrich_attributes(raw: dict[str, Any]) -> dict[str, Any]:
|
|
56
|
+
"""Apply §3 enrichment. Never adds illicit traceiq.* keys."""
|
|
57
|
+
attrs = dict(raw)
|
|
58
|
+
|
|
59
|
+
for key, value in merged_trace_attrs().items():
|
|
60
|
+
if key in attrs and attrs[key] not in (None, ""):
|
|
61
|
+
continue
|
|
62
|
+
attrs[key] = value
|
|
63
|
+
|
|
64
|
+
normalize_tokens(attrs)
|
|
65
|
+
ensure_model(attrs)
|
|
66
|
+
ensure_provider(attrs)
|
|
67
|
+
|
|
68
|
+
step = infer_step_type(attrs)
|
|
69
|
+
if step:
|
|
70
|
+
attrs[ATTR_STEP_TYPE] = step
|
|
71
|
+
|
|
72
|
+
attrs[ATTR_MODALITY] = infer_modality(attrs)
|
|
73
|
+
|
|
74
|
+
for key, value in extract_cache_usage(attrs).items():
|
|
75
|
+
attrs[key] = value
|
|
76
|
+
for key, value in extract_audio_vision_usage(attrs).items():
|
|
77
|
+
attrs[key] = value
|
|
78
|
+
|
|
79
|
+
attrs["traceiq.ingest_source"] = "sdk"
|
|
80
|
+
|
|
81
|
+
for k in [k for k in attrs if k.startswith("traceiq.") and k not in TRACEIQ_ALLOWLIST]:
|
|
82
|
+
del attrs[k]
|
|
83
|
+
|
|
84
|
+
return attrs
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def filter_for_spans_json(attrs: dict[str, Any]) -> dict[str, Any]:
|
|
88
|
+
"""Defense-in-depth for /v1/spans: only §3 + allowlisted gen_ai."""
|
|
89
|
+
out: dict[str, Any] = {}
|
|
90
|
+
for key, value in attrs.items():
|
|
91
|
+
if key.startswith("traceiq.") and key in TRACEIQ_ALLOWLIST:
|
|
92
|
+
out[key] = value
|
|
93
|
+
elif key in GEN_AI_ALLOWLIST:
|
|
94
|
+
out[key] = value
|
|
95
|
+
return out
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class LeanEnricher(SpanProcessor):
|
|
99
|
+
"""Stamp process/request context as soon as a span starts (writable)."""
|
|
100
|
+
|
|
101
|
+
def on_start(self, span: Span, parent_context: Context | None = None) -> None:
|
|
102
|
+
for key, value in merged_trace_attrs().items():
|
|
103
|
+
if key not in (span.attributes or {}):
|
|
104
|
+
span.set_attribute(key, value)
|
|
105
|
+
span.set_attribute("traceiq.ingest_source", "sdk")
|
|
106
|
+
|
|
107
|
+
def on_end(self, span: ReadableSpan) -> None:
|
|
108
|
+
return
|
|
109
|
+
|
|
110
|
+
def shutdown(self) -> None:
|
|
111
|
+
return
|
|
112
|
+
|
|
113
|
+
def force_flush(self, timeout_millis: int = 30000) -> bool:
|
|
114
|
+
return True
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class EnrichingSpanExporter(SpanExporter):
|
|
118
|
+
"""Wraps an exporter; normalizes attrs on each span before export."""
|
|
119
|
+
|
|
120
|
+
def __init__(self, inner: SpanExporter, *, spans_json_filter: bool = False) -> None:
|
|
121
|
+
self._inner = inner
|
|
122
|
+
self._spans_json_filter = spans_json_filter
|
|
123
|
+
|
|
124
|
+
def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult:
|
|
125
|
+
enriched: list[ReadableSpan] = []
|
|
126
|
+
for span in spans:
|
|
127
|
+
enriched.append(_span_with_attrs(span, self._spans_json_filter))
|
|
128
|
+
try:
|
|
129
|
+
return self._inner.export(enriched)
|
|
130
|
+
except Exception:
|
|
131
|
+
# Never break the agent path
|
|
132
|
+
import logging
|
|
133
|
+
|
|
134
|
+
logging.getLogger("traceiq").exception("span export failed")
|
|
135
|
+
return SpanExportResult.FAILURE
|
|
136
|
+
|
|
137
|
+
def shutdown(self) -> None:
|
|
138
|
+
self._inner.shutdown()
|
|
139
|
+
|
|
140
|
+
def force_flush(self, timeout_millis: int = 30000) -> bool:
|
|
141
|
+
return self._inner.force_flush(timeout_millis)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _span_with_attrs(span: ReadableSpan, spans_json_filter: bool) -> ReadableSpan:
|
|
145
|
+
raw = dict(span.attributes or {})
|
|
146
|
+
enriched = enrich_attributes(raw)
|
|
147
|
+
if spans_json_filter:
|
|
148
|
+
# Keep non-traceiq instrumentation attrs for OTLP; filter only for JSON mode
|
|
149
|
+
# Callers set spans_json_filter when using /v1/spans exporter.
|
|
150
|
+
base = filter_for_spans_json(enriched)
|
|
151
|
+
# merge back identity-only? filter_for_spans_json already has allowlist
|
|
152
|
+
enriched = base
|
|
153
|
+
|
|
154
|
+
attrs_obj = getattr(span, "_attributes", None)
|
|
155
|
+
if isinstance(attrs_obj, dict):
|
|
156
|
+
attrs_obj.clear()
|
|
157
|
+
attrs_obj.update(enriched)
|
|
158
|
+
return span
|
|
159
|
+
|
|
160
|
+
# BoundedAttributes — replace via mutable copy if possible
|
|
161
|
+
try:
|
|
162
|
+
mutable = dict(attrs_obj) if attrs_obj is not None else {}
|
|
163
|
+
mutable.clear()
|
|
164
|
+
mutable.update(enriched)
|
|
165
|
+
object.__setattr__(span, "_attributes", mutable)
|
|
166
|
+
except Exception:
|
|
167
|
+
pass
|
|
168
|
+
return span
|
traceiq/_exporter.py
ADDED
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""OTLP/HTTP JSON exporter + optional TraceIQ /v1/spans JSON + file debug sink."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
from datetime import datetime, timezone
|
|
9
|
+
from typing import Any, Sequence
|
|
10
|
+
from urllib.error import URLError
|
|
11
|
+
from urllib.request import Request, urlopen
|
|
12
|
+
|
|
13
|
+
from opentelemetry.sdk.trace import ReadableSpan
|
|
14
|
+
from opentelemetry.sdk.trace.export import SpanExporter, SpanExportResult
|
|
15
|
+
|
|
16
|
+
from traceiq._enrich import enrich_attributes, filter_for_spans_json
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger("traceiq")
|
|
19
|
+
|
|
20
|
+
OTLP_TRACES_SUFFIX = "/v1/otlp/v1/traces"
|
|
21
|
+
SPANS_PATH = "/v1/spans/batch"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def normalize_otlp_endpoint(endpoint: str) -> str:
|
|
25
|
+
"""Accept origin or full traces URL."""
|
|
26
|
+
ep = endpoint.rstrip("/")
|
|
27
|
+
if ep.endswith("/v1/otlp/v1/traces"):
|
|
28
|
+
return ep
|
|
29
|
+
if ep.endswith("/v1/otlp"):
|
|
30
|
+
return f"{ep}/v1/traces"
|
|
31
|
+
return f"{ep}{OTLP_TRACES_SUFFIX}"
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _ns_to_iso(ns: int | None) -> str | None:
|
|
35
|
+
if ns is None:
|
|
36
|
+
return None
|
|
37
|
+
return datetime.fromtimestamp(ns / 1e9, tz=timezone.utc).isoformat()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _otlp_any_value(value: Any) -> dict[str, Any]:
|
|
41
|
+
if isinstance(value, bool):
|
|
42
|
+
return {"boolValue": value}
|
|
43
|
+
if isinstance(value, int) and not isinstance(value, bool):
|
|
44
|
+
return {"intValue": str(value)}
|
|
45
|
+
if isinstance(value, float):
|
|
46
|
+
return {"doubleValue": value}
|
|
47
|
+
if isinstance(value, bytes):
|
|
48
|
+
return {"bytesValue": value.hex()}
|
|
49
|
+
return {"stringValue": str(value)}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _otlp_attributes(attrs: dict[str, Any]) -> list[dict[str, Any]]:
|
|
53
|
+
return [{"key": k, "value": _otlp_any_value(v)} for k, v in attrs.items()]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def readable_span_to_otlp(span: ReadableSpan) -> dict[str, Any]:
|
|
57
|
+
ctx = span.get_span_context()
|
|
58
|
+
parent = span.parent
|
|
59
|
+
parent_id = format(parent.span_id, "016x") if parent and parent.is_valid else ""
|
|
60
|
+
attrs = enrich_attributes(dict(span.attributes or {}))
|
|
61
|
+
status = span.status
|
|
62
|
+
status_code = 1 # UNSET
|
|
63
|
+
if status is not None:
|
|
64
|
+
# StatusCode.OK = 1 in OTel API but OTLP uses 1=OK, 2=ERROR after remap
|
|
65
|
+
from opentelemetry.trace import StatusCode
|
|
66
|
+
|
|
67
|
+
if status.status_code == StatusCode.OK:
|
|
68
|
+
status_code = 1
|
|
69
|
+
elif status.status_code == StatusCode.ERROR:
|
|
70
|
+
status_code = 2
|
|
71
|
+
body: dict[str, Any] = {
|
|
72
|
+
"traceId": format(ctx.trace_id, "032x"),
|
|
73
|
+
"spanId": format(ctx.span_id, "016x"),
|
|
74
|
+
"name": span.name,
|
|
75
|
+
"kind": int(span.kind.value) if span.kind is not None else 1,
|
|
76
|
+
"startTimeUnixNano": str(span.start_time or 0),
|
|
77
|
+
"endTimeUnixNano": str(span.end_time or 0),
|
|
78
|
+
"attributes": _otlp_attributes(attrs),
|
|
79
|
+
"status": {"code": status_code},
|
|
80
|
+
}
|
|
81
|
+
if parent_id:
|
|
82
|
+
body["parentSpanId"] = parent_id
|
|
83
|
+
return body
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def readable_span_to_traceiq_json(span: ReadableSpan) -> dict[str, Any]:
|
|
87
|
+
ctx = span.get_span_context()
|
|
88
|
+
parent = span.parent
|
|
89
|
+
parent_id = format(parent.span_id, "016x") if parent and parent.is_valid else ""
|
|
90
|
+
attrs = filter_for_spans_json(enrich_attributes(dict(span.attributes or {})))
|
|
91
|
+
start = span.start_time
|
|
92
|
+
end = span.end_time
|
|
93
|
+
duration_ms = None
|
|
94
|
+
if start is not None and end is not None:
|
|
95
|
+
duration_ms = (end - start) / 1e6
|
|
96
|
+
return {
|
|
97
|
+
"trace_id": format(ctx.trace_id, "032x"),
|
|
98
|
+
"span_id": format(ctx.span_id, "016x"),
|
|
99
|
+
"parent_span_id": parent_id,
|
|
100
|
+
"name": span.name,
|
|
101
|
+
"start_time": _ns_to_iso(start),
|
|
102
|
+
"end_time": _ns_to_iso(end),
|
|
103
|
+
"duration_ms": duration_ms,
|
|
104
|
+
"attributes": attrs,
|
|
105
|
+
"events": [],
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _post_json(url: str, api_key: str, body: dict[str, Any]) -> None:
|
|
110
|
+
data = json.dumps(body).encode("utf-8")
|
|
111
|
+
req = Request(
|
|
112
|
+
url,
|
|
113
|
+
data=data,
|
|
114
|
+
headers={
|
|
115
|
+
"Content-Type": "application/json",
|
|
116
|
+
"Authorization": f"Bearer {api_key}",
|
|
117
|
+
},
|
|
118
|
+
method="POST",
|
|
119
|
+
)
|
|
120
|
+
with urlopen(req, timeout=30) as resp:
|
|
121
|
+
resp.read()
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
class OTLPJsonSpanExporter(SpanExporter):
|
|
125
|
+
"""POST ExportTraceServiceRequest-shaped JSON to TraceIQ OTLP endpoint."""
|
|
126
|
+
|
|
127
|
+
def __init__(self, endpoint: str, api_key: str) -> None:
|
|
128
|
+
self._url = normalize_otlp_endpoint(endpoint)
|
|
129
|
+
self._api_key = api_key
|
|
130
|
+
|
|
131
|
+
def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult:
|
|
132
|
+
if not spans:
|
|
133
|
+
return SpanExportResult.SUCCESS
|
|
134
|
+
payload = {
|
|
135
|
+
"resourceSpans": [
|
|
136
|
+
{
|
|
137
|
+
"resource": {
|
|
138
|
+
"attributes": _otlp_attributes(
|
|
139
|
+
{"service.name": "traceiq-sdk"}
|
|
140
|
+
)
|
|
141
|
+
},
|
|
142
|
+
"scopeSpans": [
|
|
143
|
+
{
|
|
144
|
+
"scope": {"name": "traceiq", "version": "0.1.0"},
|
|
145
|
+
"spans": [readable_span_to_otlp(s) for s in spans],
|
|
146
|
+
}
|
|
147
|
+
],
|
|
148
|
+
}
|
|
149
|
+
]
|
|
150
|
+
}
|
|
151
|
+
try:
|
|
152
|
+
_post_json(self._url, self._api_key, payload)
|
|
153
|
+
return SpanExportResult.SUCCESS
|
|
154
|
+
except (URLError, OSError, TimeoutError) as exc:
|
|
155
|
+
logger.warning("OTLP export failed: %s", exc)
|
|
156
|
+
return SpanExportResult.FAILURE
|
|
157
|
+
|
|
158
|
+
def shutdown(self) -> None:
|
|
159
|
+
return
|
|
160
|
+
|
|
161
|
+
def force_flush(self, timeout_millis: int = 30000) -> bool:
|
|
162
|
+
return True
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class TraceIQSpansJsonExporter(SpanExporter):
|
|
166
|
+
"""Fallback POST /v1/spans/batch with filtered attributes."""
|
|
167
|
+
|
|
168
|
+
def __init__(self, endpoint: str, api_key: str) -> None:
|
|
169
|
+
base = endpoint.rstrip("/")
|
|
170
|
+
if base.endswith("/v1/spans") or base.endswith("/v1/spans/batch"):
|
|
171
|
+
self._url = base if base.endswith("/batch") else f"{base}/batch"
|
|
172
|
+
else:
|
|
173
|
+
self._url = f"{base}{SPANS_PATH}"
|
|
174
|
+
self._api_key = api_key
|
|
175
|
+
|
|
176
|
+
def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult:
|
|
177
|
+
if not spans:
|
|
178
|
+
return SpanExportResult.SUCCESS
|
|
179
|
+
body = {"spans": [readable_span_to_traceiq_json(s) for s in spans]}
|
|
180
|
+
try:
|
|
181
|
+
_post_json(self._url, self._api_key, body)
|
|
182
|
+
return SpanExportResult.SUCCESS
|
|
183
|
+
except (URLError, OSError, TimeoutError) as exc:
|
|
184
|
+
logger.warning("spans JSON export failed: %s", exc)
|
|
185
|
+
return SpanExportResult.FAILURE
|
|
186
|
+
|
|
187
|
+
def shutdown(self) -> None:
|
|
188
|
+
return
|
|
189
|
+
|
|
190
|
+
def force_flush(self, timeout_millis: int = 30000) -> bool:
|
|
191
|
+
return True
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class FileSpanExporter(SpanExporter):
|
|
195
|
+
"""Local debug: write enriched OTLP-ish JSON files under ./traces/."""
|
|
196
|
+
|
|
197
|
+
def __init__(self, directory: str = "./traces") -> None:
|
|
198
|
+
self._dir = directory
|
|
199
|
+
os.makedirs(self._dir, exist_ok=True)
|
|
200
|
+
|
|
201
|
+
def export(self, spans: Sequence[ReadableSpan]) -> SpanExportResult:
|
|
202
|
+
for span in spans:
|
|
203
|
+
doc = readable_span_to_otlp(span)
|
|
204
|
+
path = os.path.join(
|
|
205
|
+
self._dir,
|
|
206
|
+
f"{doc['traceId']}_{doc['spanId']}.json",
|
|
207
|
+
)
|
|
208
|
+
try:
|
|
209
|
+
with open(path, "w", encoding="utf-8") as fh:
|
|
210
|
+
json.dump(doc, fh, indent=2)
|
|
211
|
+
except OSError as exc:
|
|
212
|
+
logger.warning("file export failed: %s", exc)
|
|
213
|
+
return SpanExportResult.FAILURE
|
|
214
|
+
return SpanExportResult.SUCCESS
|
|
215
|
+
|
|
216
|
+
def shutdown(self) -> None:
|
|
217
|
+
return
|
|
218
|
+
|
|
219
|
+
def force_flush(self, timeout_millis: int = 30000) -> bool:
|
|
220
|
+
return True
|
traceiq/_ids.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Stable id helpers for workflow_id derivation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def stable_workflow_id(name: str) -> str:
|
|
9
|
+
"""Deterministic workflow_id from a human name: sha1(name)[:16]."""
|
|
10
|
+
return hashlib.sha1(name.encode("utf-8")).hexdigest()[:16]
|
traceiq/_init.py
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""traceiq.init / flush — TracerProvider, instrumentations, exporters."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import atexit
|
|
6
|
+
import importlib
|
|
7
|
+
import logging
|
|
8
|
+
import os
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from opentelemetry import trace
|
|
12
|
+
from opentelemetry.sdk.resources import Resource
|
|
13
|
+
from opentelemetry.sdk.trace import TracerProvider
|
|
14
|
+
from opentelemetry.sdk.trace.export import (
|
|
15
|
+
BatchSpanProcessor,
|
|
16
|
+
SimpleSpanProcessor,
|
|
17
|
+
SpanExporter,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
from traceiq._context import configure_globals, get_globals
|
|
21
|
+
from traceiq._enrich import EnrichingSpanExporter, LeanEnricher
|
|
22
|
+
from traceiq._exporter import (
|
|
23
|
+
FileSpanExporter,
|
|
24
|
+
OTLPJsonSpanExporter,
|
|
25
|
+
TraceIQSpansJsonExporter,
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger("traceiq")
|
|
29
|
+
|
|
30
|
+
FLUSH_INTERVAL_MS = 5000
|
|
31
|
+
MAX_EXPORT_BATCH_SIZE = 512
|
|
32
|
+
|
|
33
|
+
_provider: TracerProvider | None = None
|
|
34
|
+
_atexit_registered = False
|
|
35
|
+
_misconfig_warned = False
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _instrument_installed() -> None:
|
|
39
|
+
for mod, starter in [
|
|
40
|
+
("opentelemetry.instrumentation.openai", "OpenAIInstrumentor"),
|
|
41
|
+
("opentelemetry.instrumentation.anthropic", "AnthropicInstrumentor"),
|
|
42
|
+
("opentelemetry.instrumentation.langchain", "LangchainInstrumentor"),
|
|
43
|
+
("opentelemetry.instrumentation.llamaindex", "LlamaIndexInstrumentor"),
|
|
44
|
+
]:
|
|
45
|
+
try:
|
|
46
|
+
m = importlib.import_module(mod)
|
|
47
|
+
getattr(m, starter)().instrument()
|
|
48
|
+
logger.debug("instrumented %s", starter)
|
|
49
|
+
except Exception:
|
|
50
|
+
continue
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _build_exporter(
|
|
54
|
+
*,
|
|
55
|
+
endpoint: str,
|
|
56
|
+
api_key: str,
|
|
57
|
+
export_mode: str,
|
|
58
|
+
) -> SpanExporter:
|
|
59
|
+
if not endpoint:
|
|
60
|
+
return FileSpanExporter("./traces")
|
|
61
|
+
if export_mode == "spans":
|
|
62
|
+
return TraceIQSpansJsonExporter(endpoint, api_key)
|
|
63
|
+
return OTLPJsonSpanExporter(endpoint, api_key)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def init(
|
|
67
|
+
*,
|
|
68
|
+
api_key: str | None = None,
|
|
69
|
+
endpoint: str | None = None,
|
|
70
|
+
app_name: str | None = None,
|
|
71
|
+
environment: str | None = None,
|
|
72
|
+
export_mode: str = "otlp",
|
|
73
|
+
disable_batch: bool = False,
|
|
74
|
+
exporter: SpanExporter | None = None,
|
|
75
|
+
) -> None:
|
|
76
|
+
"""
|
|
77
|
+
Configure TracerProvider, LeanEnricher, batch export, and installed OTel extras.
|
|
78
|
+
|
|
79
|
+
Zero-arg happy path: reads TRACEIQ_API_KEY, TRACEIQ_ENDPOINT, TRACEIQ_APP_NAME,
|
|
80
|
+
TRACEIQ_ENVIRONMENT from the environment. Empty endpoint → ./traces/ file export.
|
|
81
|
+
"""
|
|
82
|
+
global _provider, _atexit_registered, _misconfig_warned
|
|
83
|
+
|
|
84
|
+
key = api_key if api_key is not None else os.environ.get("TRACEIQ_API_KEY", "")
|
|
85
|
+
ep = endpoint if endpoint is not None else os.environ.get("TRACEIQ_ENDPOINT", "")
|
|
86
|
+
name = (
|
|
87
|
+
app_name
|
|
88
|
+
if app_name is not None
|
|
89
|
+
else os.environ.get("TRACEIQ_APP_NAME", "traceiq-app")
|
|
90
|
+
) or "traceiq-app"
|
|
91
|
+
env = (
|
|
92
|
+
environment
|
|
93
|
+
if environment is not None
|
|
94
|
+
else os.environ.get("TRACEIQ_ENVIRONMENT", "")
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
if ep and not key and not _misconfig_warned:
|
|
98
|
+
logger.warning(
|
|
99
|
+
"TRACEIQ_ENDPOINT is set but TRACEIQ_API_KEY is empty — "
|
|
100
|
+
"OTLP export will likely be rejected"
|
|
101
|
+
)
|
|
102
|
+
_misconfig_warned = True
|
|
103
|
+
if not ep and exporter is None:
|
|
104
|
+
logger.info("No TRACEIQ_ENDPOINT — writing spans to ./traces/")
|
|
105
|
+
|
|
106
|
+
configure_globals(
|
|
107
|
+
app_name=name,
|
|
108
|
+
environment=env,
|
|
109
|
+
api_key=key,
|
|
110
|
+
endpoint=ep,
|
|
111
|
+
export_mode=export_mode,
|
|
112
|
+
disable_batch=disable_batch,
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
resource = Resource.create({"service.name": name})
|
|
116
|
+
provider = TracerProvider(resource=resource)
|
|
117
|
+
provider.add_span_processor(LeanEnricher())
|
|
118
|
+
|
|
119
|
+
inner = exporter or _build_exporter(
|
|
120
|
+
endpoint=ep, api_key=key, export_mode=export_mode
|
|
121
|
+
)
|
|
122
|
+
# Always run enrichment before the concrete exporter
|
|
123
|
+
wrapped = EnrichingSpanExporter(
|
|
124
|
+
inner, spans_json_filter=(export_mode == "spans")
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
if disable_batch:
|
|
128
|
+
provider.add_span_processor(SimpleSpanProcessor(wrapped))
|
|
129
|
+
else:
|
|
130
|
+
provider.add_span_processor(
|
|
131
|
+
BatchSpanProcessor(
|
|
132
|
+
wrapped,
|
|
133
|
+
schedule_delay_millis=FLUSH_INTERVAL_MS,
|
|
134
|
+
max_export_batch_size=MAX_EXPORT_BATCH_SIZE,
|
|
135
|
+
)
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
try:
|
|
139
|
+
trace.set_tracer_provider(provider)
|
|
140
|
+
except Exception:
|
|
141
|
+
pass
|
|
142
|
+
_provider = provider
|
|
143
|
+
|
|
144
|
+
_instrument_installed()
|
|
145
|
+
|
|
146
|
+
if not _atexit_registered:
|
|
147
|
+
atexit.register(_atexit_flush)
|
|
148
|
+
_atexit_registered = True
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def flush(timeout_millis: int = 10_000) -> bool:
|
|
152
|
+
if _provider is None:
|
|
153
|
+
return True
|
|
154
|
+
return _provider.force_flush(timeout_millis)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _atexit_flush() -> None:
|
|
158
|
+
try:
|
|
159
|
+
flush()
|
|
160
|
+
if _provider is not None:
|
|
161
|
+
_provider.shutdown()
|
|
162
|
+
except Exception:
|
|
163
|
+
pass
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def get_provider() -> TracerProvider | None:
|
|
167
|
+
return _provider
|
traceiq/_modality.py
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"""Infer traceiq.modality from usage / request shape."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Mapping
|
|
6
|
+
|
|
7
|
+
ATTR_MODALITY = "traceiq.modality"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def infer_modality(attrs: Mapping[str, Any]) -> str:
|
|
11
|
+
existing = attrs.get(ATTR_MODALITY)
|
|
12
|
+
if existing:
|
|
13
|
+
return str(existing)
|
|
14
|
+
|
|
15
|
+
has_vision = bool(
|
|
16
|
+
attrs.get("traceiq.usage.vision_tokens")
|
|
17
|
+
or attrs.get("gen_ai.usage.image_tokens")
|
|
18
|
+
or attrs.get("gen_ai.prompt.has_images")
|
|
19
|
+
)
|
|
20
|
+
has_audio = bool(
|
|
21
|
+
attrs.get("traceiq.usage.audio_input_tokens")
|
|
22
|
+
or attrs.get("traceiq.usage.audio_output_tokens")
|
|
23
|
+
or attrs.get("gen_ai.usage.input_audio_tokens")
|
|
24
|
+
or attrs.get("gen_ai.usage.output_audio_tokens")
|
|
25
|
+
)
|
|
26
|
+
if has_vision and has_audio:
|
|
27
|
+
return "multimodal"
|
|
28
|
+
if has_vision:
|
|
29
|
+
return "vision"
|
|
30
|
+
if has_audio:
|
|
31
|
+
return "audio"
|
|
32
|
+
return "text"
|
traceiq/_step.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Infer traceiq.step_type from decorator marks or span attrs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Mapping
|
|
6
|
+
|
|
7
|
+
ATTR_STEP_TYPE = "traceiq.step_type"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def infer_step_type(attrs: Mapping[str, Any]) -> str | None:
|
|
11
|
+
existing = attrs.get(ATTR_STEP_TYPE)
|
|
12
|
+
if existing:
|
|
13
|
+
return str(existing)
|
|
14
|
+
|
|
15
|
+
# Tool signals from instrumentations
|
|
16
|
+
if attrs.get("gen_ai.tool.name") or attrs.get("traceloop.entity.name") == "tool":
|
|
17
|
+
return "tool"
|
|
18
|
+
|
|
19
|
+
# LLM signals
|
|
20
|
+
if (
|
|
21
|
+
attrs.get("gen_ai.request.model")
|
|
22
|
+
or attrs.get("gen_ai.usage.input_tokens") is not None
|
|
23
|
+
or attrs.get("gen_ai.usage.prompt_tokens") is not None
|
|
24
|
+
):
|
|
25
|
+
return "llm"
|
|
26
|
+
|
|
27
|
+
return None
|
traceiq/decorators.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""@workflow / @task / @tool / @agent — thin OTel decorators (sync + async)."""
|
|
2
|
+
|
|
3
|
+
# pyright: reportAttributeAccessIssue=false, reportArgumentType=false, reportAssignmentType=false
|
|
4
|
+
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import functools
|
|
8
|
+
import inspect
|
|
9
|
+
from typing import Any, Callable, TypeVar
|
|
10
|
+
|
|
11
|
+
from opentelemetry import trace
|
|
12
|
+
from opentelemetry.trace import Status, StatusCode
|
|
13
|
+
|
|
14
|
+
from traceiq._context import set_context
|
|
15
|
+
from traceiq._ids import stable_workflow_id
|
|
16
|
+
from traceiq._init import get_provider
|
|
17
|
+
|
|
18
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
19
|
+
|
|
20
|
+
TRACER_NAME = "traceiq"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _tracer():
|
|
24
|
+
"""
|
|
25
|
+
Internal helper to obtain an OpenTelemetry tracer instance.
|
|
26
|
+
Code purpose: Ensures all tracing in this module uses a consistent tracer and version, whether or not a custom trace provider is registered.
|
|
27
|
+
Business purpose: Establishes the baseline for all traceable workflow, task, tool, and agent operations for downstream observability.
|
|
28
|
+
"""
|
|
29
|
+
provider = get_provider()
|
|
30
|
+
if provider is not None:
|
|
31
|
+
return provider.get_tracer(TRACER_NAME, "0.1.0")
|
|
32
|
+
return trace.get_tracer(TRACER_NAME, "0.1.0")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _apply_kind_attrs(span: Any, kind: str, span_name: str, agent_name: str | None) -> None:
|
|
36
|
+
"""
|
|
37
|
+
Adds kind-specific attributes to the OpenTelemetry span and updates TraceIQ context.
|
|
38
|
+
Code purpose: Configures metadata on spans according to their semantic kind (workflow, agent, etc.) for accurate attribution.
|
|
39
|
+
Business purpose: Enables filtering, grouping, and analysis of traces by logical step type in downstream analytics and dashboards.
|
|
40
|
+
"""
|
|
41
|
+
span.set_attribute("traceiq.step_type", kind)
|
|
42
|
+
if kind == "workflow":
|
|
43
|
+
span.set_attribute("traceiq.trace.root", True)
|
|
44
|
+
wf_id = stable_workflow_id(span_name)
|
|
45
|
+
span.set_attribute("traceiq.workflow_id", wf_id)
|
|
46
|
+
set_context(workflow_id=wf_id)
|
|
47
|
+
if kind == "agent":
|
|
48
|
+
label = agent_name or span_name
|
|
49
|
+
span.set_attribute("traceiq.agent_name", label)
|
|
50
|
+
set_context(agent_name=label)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _decorate(kind: str, name: str | None, agent_name: str | None, fn: F) -> F:
|
|
54
|
+
"""
|
|
55
|
+
Decorator implementation for workflow, task, tool, and agent.
|
|
56
|
+
Code purpose: Wraps sync and async functions in a tracing span, sets span attributes, captures errors, and manages context propagation.
|
|
57
|
+
Business purpose: Provides standardized, zero-boilerplate tracing instrumentation for business-critical process and execution steps.
|
|
58
|
+
"""
|
|
59
|
+
span_name = name or fn.__name__
|
|
60
|
+
|
|
61
|
+
if inspect.iscoroutinefunction(fn):
|
|
62
|
+
|
|
63
|
+
@functools.wraps(fn)
|
|
64
|
+
async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
65
|
+
with _tracer().start_as_current_span(span_name) as span:
|
|
66
|
+
_apply_kind_attrs(span, kind, span_name, agent_name)
|
|
67
|
+
try:
|
|
68
|
+
return await fn(*args, **kwargs)
|
|
69
|
+
except Exception as exc:
|
|
70
|
+
span.set_status(Status(StatusCode.ERROR, str(exc)))
|
|
71
|
+
span.record_exception(exc)
|
|
72
|
+
raise
|
|
73
|
+
else:
|
|
74
|
+
span.set_status(Status(StatusCode.OK))
|
|
75
|
+
|
|
76
|
+
return async_wrapper # type: ignore[return-value]
|
|
77
|
+
|
|
78
|
+
@functools.wraps(fn)
|
|
79
|
+
def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
|
|
80
|
+
with _tracer().start_as_current_span(span_name) as span:
|
|
81
|
+
_apply_kind_attrs(span, kind, span_name, agent_name)
|
|
82
|
+
try:
|
|
83
|
+
return fn(*args, **kwargs)
|
|
84
|
+
except Exception as exc:
|
|
85
|
+
span.set_status(Status(StatusCode.ERROR, str(exc)))
|
|
86
|
+
span.record_exception(exc)
|
|
87
|
+
raise
|
|
88
|
+
else:
|
|
89
|
+
span.set_status(Status(StatusCode.OK))
|
|
90
|
+
|
|
91
|
+
return sync_wrapper # type: ignore[return-value]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _factory(kind: str, name: str | None = None, agent_name: str | None = None):
|
|
95
|
+
"""
|
|
96
|
+
Returns an appropriate decorator for the requested kind.
|
|
97
|
+
Code purpose: Supports flexible decorator syntax for both parameterized and parameterless use.
|
|
98
|
+
Business purpose: Allows business logic to be annotated with tracing decorators in multiple ergonomic styles.
|
|
99
|
+
"""
|
|
100
|
+
def decorator(fn: F) -> F:
|
|
101
|
+
return _decorate(kind, name, agent_name, fn)
|
|
102
|
+
|
|
103
|
+
return decorator
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def workflow(name: str | None = None) -> Any:
|
|
107
|
+
"""
|
|
108
|
+
Decorator for marking a function as a workflow for tracing.
|
|
109
|
+
Code purpose: Provides an entrypoint to instrument top-level orchestrations with TraceIQ tracing.
|
|
110
|
+
Business purpose: Identifies root workflow steps for business process tracking and performance reporting.
|
|
111
|
+
"""
|
|
112
|
+
if callable(name):
|
|
113
|
+
return _decorate("workflow", name.__name__, None, name)
|
|
114
|
+
return _factory("workflow", name=name)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def task(name: str | None = None) -> Any:
|
|
118
|
+
"""
|
|
119
|
+
Decorator for marking a function as a task for tracing.
|
|
120
|
+
Code purpose: Supports the recording of logical tasks within a workflow for detailed trace breakdowns.
|
|
121
|
+
Business purpose: Enables analysis of individual business operations and their performance within orchestration.
|
|
122
|
+
"""
|
|
123
|
+
if callable(name):
|
|
124
|
+
return _decorate("task", name.__name__, None, name)
|
|
125
|
+
return _factory("task", name=name)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def tool(name: str | None = None) -> Any:
|
|
129
|
+
"""
|
|
130
|
+
Decorator for marking a function as a tool step for tracing.
|
|
131
|
+
Code purpose: Tracks invocation and performance of tools/utilities used within workflows/tasks.
|
|
132
|
+
Business purpose: Allows correlation of supporting tool usage within business processes for audit and optimization.
|
|
133
|
+
"""
|
|
134
|
+
if callable(name):
|
|
135
|
+
return _decorate("tool", name.__name__, None, name)
|
|
136
|
+
return _factory("tool", name=name)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def agent(name: str | None = None) -> Any:
|
|
140
|
+
"""
|
|
141
|
+
Decorator for marking a function as an agent step for tracing.
|
|
142
|
+
Code purpose: Adds agent-level attributes and enables trace splitting/labeling for agent actions.
|
|
143
|
+
Business purpose: Supports agentized business logic, allowing per-agent metrics, reporting, and debugging.
|
|
144
|
+
"""
|
|
145
|
+
if callable(name):
|
|
146
|
+
return _decorate("agent", name.__name__, name.__name__, name)
|
|
147
|
+
return _factory("agent", name=name, agent_name=name)
|
traceiq/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: traceiq-capture
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: TraceIQ Capture — OpenTelemetry instrumentation with a locked attribute contract
|
|
5
|
+
Author: TraceIQ
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
License-File: LICENSE
|
|
8
|
+
Keywords: ai,cost,llm,observability,opentelemetry,otlp
|
|
9
|
+
Requires-Python: >=3.10
|
|
10
|
+
Requires-Dist: opentelemetry-api>=1.27.0
|
|
11
|
+
Requires-Dist: opentelemetry-exporter-otlp-proto-http>=1.27.0
|
|
12
|
+
Requires-Dist: opentelemetry-sdk>=1.27.0
|
|
13
|
+
Provides-Extra: all
|
|
14
|
+
Requires-Dist: openai; extra == 'all'
|
|
15
|
+
Requires-Dist: opentelemetry-instrumentation-anthropic; extra == 'all'
|
|
16
|
+
Requires-Dist: opentelemetry-instrumentation-langchain; extra == 'all'
|
|
17
|
+
Requires-Dist: opentelemetry-instrumentation-llamaindex; extra == 'all'
|
|
18
|
+
Requires-Dist: opentelemetry-instrumentation-openai; extra == 'all'
|
|
19
|
+
Provides-Extra: anthropic
|
|
20
|
+
Requires-Dist: opentelemetry-instrumentation-anthropic; extra == 'anthropic'
|
|
21
|
+
Provides-Extra: dev
|
|
22
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
23
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
24
|
+
Provides-Extra: langchain
|
|
25
|
+
Requires-Dist: opentelemetry-instrumentation-langchain; extra == 'langchain'
|
|
26
|
+
Provides-Extra: langgraph
|
|
27
|
+
Requires-Dist: langgraph; extra == 'langgraph'
|
|
28
|
+
Requires-Dist: opentelemetry-instrumentation-langchain; extra == 'langgraph'
|
|
29
|
+
Provides-Extra: llamaindex
|
|
30
|
+
Requires-Dist: opentelemetry-instrumentation-llamaindex; extra == 'llamaindex'
|
|
31
|
+
Provides-Extra: openai
|
|
32
|
+
Requires-Dist: openai; extra == 'openai'
|
|
33
|
+
Requires-Dist: opentelemetry-instrumentation-openai; extra == 'openai'
|
|
34
|
+
Description-Content-Type: text/markdown
|
|
35
|
+
|
|
36
|
+
# TraceIQ Capture (`traceiq-capture`)
|
|
37
|
+
|
|
38
|
+
OpenTelemetry-native Python SDK for AI agent / LLM cost observability. Locked attribute contract (no field sprawl). Framework coverage via optional OTel instrumentations — not Traceloop.
|
|
39
|
+
|
|
40
|
+
Install name: **`traceiq-capture`**. Import: **`import traceiq`**.
|
|
41
|
+
|
|
42
|
+
## Install
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
cd sdk
|
|
46
|
+
pip install -e . # installs package traceiq-capture
|
|
47
|
+
pip install -e ".[openai]" # OpenAI auto-instrumentation
|
|
48
|
+
pip install -e ".[langchain]" # LangChain / LangGraph
|
|
49
|
+
pip install -e ".[anthropic]"
|
|
50
|
+
pip install -e ".[llamaindex]"
|
|
51
|
+
pip install -e ".[all]"
|
|
52
|
+
pip install -e ".[dev]" # pytest
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## Quickstart
|
|
56
|
+
|
|
57
|
+
Set env (optional for local file export):
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
export TRACEIQ_API_KEY=...
|
|
61
|
+
export TRACEIQ_ENDPOINT=https://api.example.com # omit → ./traces/
|
|
62
|
+
export TRACEIQ_APP_NAME=support-agent-api # optional
|
|
63
|
+
export TRACEIQ_ENVIRONMENT=production # optional
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
import traceiq
|
|
68
|
+
|
|
69
|
+
traceiq.init()
|
|
70
|
+
|
|
71
|
+
# Existing OpenAI / LangChain / Anthropic / LlamaIndex code unchanged.
|
|
72
|
+
# Installed extras are auto-instrumented on init — no decorators required.
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
No `TRACEIQ_ENDPOINT` → spans written under `./traces/` for local debug. Batch export every **5s**; process exit flushes automatically (`flush()` is only needed in short scripts/tests).
|
|
76
|
+
|
|
77
|
+
## Optional: agent trees and attribution
|
|
78
|
+
|
|
79
|
+
Use decorators when you want a run root and step types. Use `set_context` when you want product / project / agent rollups.
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
from traceiq import workflow, set_context
|
|
83
|
+
|
|
84
|
+
@workflow(name="triage_ticket")
|
|
85
|
+
def handle(ticket_id: str, user_msg: str):
|
|
86
|
+
set_context(agent_name="triage", product_name="Pro")
|
|
87
|
+
return run_agent(user_msg)
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
Also available: `@task`, `@tool`, `@agent`, `clear_context`.
|
|
91
|
+
|
|
92
|
+
## Frameworks
|
|
93
|
+
|
|
94
|
+
Install the matching extra; call `init()` once. LLM library calls emit child spans automatically.
|
|
95
|
+
|
|
96
|
+
| Stack | Extra |
|
|
97
|
+
|-------|-------|
|
|
98
|
+
| OpenAI | `[openai]` |
|
|
99
|
+
| Anthropic | `[anthropic]` |
|
|
100
|
+
| LangChain / LangGraph | `[langchain]` / `[langgraph]` |
|
|
101
|
+
| LlamaIndex | `[llamaindex]` |
|
|
102
|
+
| CrewAI | `[openai]` and/or `[langchain]` |
|
|
103
|
+
|
|
104
|
+
Decorators are optional — add `@workflow` on your entrypoint only if you want TraceIQ run identity around the call tree.
|
|
105
|
+
|
|
106
|
+
## Public API
|
|
107
|
+
|
|
108
|
+
`init`, `flush`, `set_context`, `clear_context`, `@workflow`, `@task`, `@tool`, `@agent`
|
|
109
|
+
|
|
110
|
+
## Export
|
|
111
|
+
|
|
112
|
+
- Default: OTLP/HTTP JSON → `{endpoint}/v1/otlp/v1/traces` (Bearer)
|
|
113
|
+
- Fallback: `export_mode="spans"` → `/v1/spans/batch`
|
|
114
|
+
- Batch every **5s** (`disable_batch=True` for sync scripts)
|
|
115
|
+
|
|
116
|
+
## Attributes (locked)
|
|
117
|
+
|
|
118
|
+
See build spec §3. Only allowlisted `traceiq.*` plus `gen_ai.system` / `gen_ai.request.model` / usage tokens. No prompt/completion content capture. No client-side pricing.
|
|
119
|
+
|
|
120
|
+
## Develop
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
pip install -e ".[dev]"
|
|
124
|
+
pytest
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
## License
|
|
128
|
+
|
|
129
|
+
MIT
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
traceiq/__init__.py,sha256=6O66QL9W5113kyAS1LPYYBkopbt7-zFm2Jl9ztqZbWk,399
|
|
2
|
+
traceiq/_compat.py,sha256=IrKJnwkSEpE8ATvk1N3beIGDyIxZ8x3LuinJP1qcV0g,3792
|
|
3
|
+
traceiq/_context.py,sha256=gG-TQxW5G990Tq057-VQCgVeukmhudavUV-kn5frFiE,3147
|
|
4
|
+
traceiq/_enrich.py,sha256=ga8byKFLnkkdVBxopB69D1WpehzHZ9m-PvfIekvVZL8,5287
|
|
5
|
+
traceiq/_exporter.py,sha256=MzC3xFx203UeNDMM3BJBUqNyLrZJmDCXshrE-PG_Hy8,7418
|
|
6
|
+
traceiq/_ids.py,sha256=9rRszlBimKpr8eYNpxyPPYcGe6IejLade4tMKpfG7yk,283
|
|
7
|
+
traceiq/_init.py,sha256=2MKuqgnzAhvwOP2cBK3AF5e_kJYtUFx3XotPV18wRGE,4724
|
|
8
|
+
traceiq/_modality.py,sha256=biBeDIqY8iWevwVf9Nc1RhWA_LYt0ee_LUGYo_SFlYQ,897
|
|
9
|
+
traceiq/_step.py,sha256=HUYgLvRr2zpjM9qegazuj2wc6qxDMAtWiVi5FXjh-vg,713
|
|
10
|
+
traceiq/decorators.py,sha256=3YDzYZtw1l9nTu5xZxMaUV_rzGQOGcbj4GdbI7WXc38,6110
|
|
11
|
+
traceiq/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
12
|
+
traceiq_capture-0.1.0.dist-info/METADATA,sha256=Ooq1gCaG_mMXX2x7BWpbSBkqg_H8UMkm8f3CUlbB0XE,4335
|
|
13
|
+
traceiq_capture-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
14
|
+
traceiq_capture-0.1.0.dist-info/licenses/LICENSE,sha256=AcIBGJD8Of_dlodvArZCiQ1JrIxG-lt923vdALyL7sc,1064
|
|
15
|
+
traceiq_capture-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 TraceIQ
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|