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 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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.