redundo 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.
- redundo/__init__.py +10 -0
- redundo/adapter/__init__.py +13 -0
- redundo/adapter/cli.py +135 -0
- redundo/adapter/collector.py +171 -0
- redundo/adapter/detect.py +136 -0
- redundo/adapter/hashing.py +145 -0
- redundo/adapter/otlp.py +207 -0
- redundo/adapter/sources/__init__.py +6 -0
- redundo/adapter/sources/claude_code.py +830 -0
- redundo/adapter/sources/cowork.py +378 -0
- redundo/adapter/sources/openclaw.py +466 -0
- redundo/adapter/sources/openinference.py +482 -0
- redundo/adapter/writer.py +20 -0
- redundo/analyzer/__init__.py +37 -0
- redundo/analyzer/classify.py +241 -0
- redundo/analyzer/cli.py +104 -0
- redundo/analyzer/cycles.py +96 -0
- redundo/analyzer/ingest.py +121 -0
- redundo/analyzer/lineage.py +118 -0
- redundo/analyzer/metrics.py +244 -0
- redundo/analyzer/report.py +434 -0
- redundo/analyzer/schema.py +171 -0
- redundo/cli.py +58 -0
- redundo-0.1.0.dist-info/METADATA +390 -0
- redundo-0.1.0.dist-info/RECORD +28 -0
- redundo-0.1.0.dist-info/WHEEL +4 -0
- redundo-0.1.0.dist-info/entry_points.txt +2 -0
- redundo-0.1.0.dist-info/licenses/LICENSE +21 -0
redundo/__init__.py
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""redundo: adapt agent traces to a common schema, then analyze them.
|
|
2
|
+
|
|
3
|
+
redundo adapt --source openinference traces/ | redundo analyze > report.html
|
|
4
|
+
|
|
5
|
+
`redundo.adapter` and `redundo.analyzer` are independent packages
|
|
6
|
+
joined by one contract, `redundo.analyzer.schema.Event` -- `analyze`
|
|
7
|
+
doesn't know or care whether its input came from `adapt` or was hand-built
|
|
8
|
+
NDJSON from a source this project doesn't support yet. See each
|
|
9
|
+
subpackage's own docstring for its half of the pipeline.
|
|
10
|
+
"""
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from .detect import Detection, DetectionError, Source, detect_source
|
|
2
|
+
from .sources import convert_claude_code, convert_cowork, convert_openclaw, convert_openinference
|
|
3
|
+
|
|
4
|
+
__all__ = [
|
|
5
|
+
"Source",
|
|
6
|
+
"Detection",
|
|
7
|
+
"DetectionError",
|
|
8
|
+
"detect_source",
|
|
9
|
+
"convert_openinference",
|
|
10
|
+
"convert_claude_code",
|
|
11
|
+
"convert_cowork",
|
|
12
|
+
"convert_openclaw",
|
|
13
|
+
]
|
redundo/adapter/cli.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""CLI entry point: a directory of captured OTLP JSON batches -> JSONL
|
|
2
|
+
matching the redundo.analyzer schema contract.
|
|
3
|
+
|
|
4
|
+
redundo adapt ./otlp_traces -o trace.jsonl
|
|
5
|
+
|
|
6
|
+
The source (OpenInference/Hermes, Claude Code, Cowork, or OpenClaw) is auto-detected
|
|
7
|
+
from the captured data itself -- see detect.py for exactly how, and
|
|
8
|
+
`--source` to skip detection and force one explicitly. A directory is
|
|
9
|
+
accepted rather than a single file because every source's own exporter
|
|
10
|
+
flushes on an interval, producing many small batch files per session
|
|
11
|
+
rather than one large export.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import json
|
|
18
|
+
import sys
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from .detect import DetectionError, Source, detect_source
|
|
22
|
+
from .otlp import OtlpParseError, is_log_document, is_trace_document
|
|
23
|
+
from .sources import convert_claude_code, convert_cowork, convert_openclaw, convert_openinference
|
|
24
|
+
from .writer import write_jsonl
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
28
|
+
parser = argparse.ArgumentParser(
|
|
29
|
+
prog="redundo adapt",
|
|
30
|
+
description="Convert a directory of captured OTLP JSON batches into JSONL "
|
|
31
|
+
"matching the redundo analyze schema contract. The source is "
|
|
32
|
+
"auto-detected from the data; pass --source to override.",
|
|
33
|
+
)
|
|
34
|
+
parser.add_argument(
|
|
35
|
+
"otlp_dir", type=Path,
|
|
36
|
+
help="Directory of *.json OTLP export batch files (traces and/or logs, any mix)",
|
|
37
|
+
)
|
|
38
|
+
parser.add_argument(
|
|
39
|
+
"-o", "--output", type=Path, default=None, metavar="PATH",
|
|
40
|
+
help="Write JSONL here instead of stdout",
|
|
41
|
+
)
|
|
42
|
+
parser.add_argument(
|
|
43
|
+
"--source", choices=[s.value for s in Source], default=None,
|
|
44
|
+
help="Skip auto-detection and force this source",
|
|
45
|
+
)
|
|
46
|
+
parser.add_argument(
|
|
47
|
+
"--summary",
|
|
48
|
+
action="store_true",
|
|
49
|
+
help="Print the conversion summary (record counts, content-provenance "
|
|
50
|
+
"stats, per-source notes) to stderr",
|
|
51
|
+
)
|
|
52
|
+
return parser
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def main(argv: list[str] | None = None) -> int:
|
|
56
|
+
parser = build_parser()
|
|
57
|
+
args = parser.parse_args(argv)
|
|
58
|
+
|
|
59
|
+
if not args.otlp_dir.is_dir():
|
|
60
|
+
print(f"redundo adapt: not a directory: {args.otlp_dir}", file=sys.stderr)
|
|
61
|
+
return 1
|
|
62
|
+
|
|
63
|
+
documents = []
|
|
64
|
+
skipped_unrecognized = 0
|
|
65
|
+
for path in sorted(args.otlp_dir.glob("*.json")):
|
|
66
|
+
try:
|
|
67
|
+
doc = json.loads(path.read_text(encoding="utf-8"))
|
|
68
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
69
|
+
print(f"redundo adapt: {path}: {exc}", file=sys.stderr)
|
|
70
|
+
return 1
|
|
71
|
+
if is_trace_document(doc) or is_log_document(doc):
|
|
72
|
+
documents.append(doc)
|
|
73
|
+
else:
|
|
74
|
+
skipped_unrecognized += 1
|
|
75
|
+
|
|
76
|
+
if not documents:
|
|
77
|
+
print(
|
|
78
|
+
f"redundo adapt: no OTLP trace or log documents found in {args.otlp_dir}",
|
|
79
|
+
file=sys.stderr,
|
|
80
|
+
)
|
|
81
|
+
return 1
|
|
82
|
+
|
|
83
|
+
if args.source is not None:
|
|
84
|
+
source = Source(args.source)
|
|
85
|
+
reason = "--source flag"
|
|
86
|
+
else:
|
|
87
|
+
try:
|
|
88
|
+
detection = detect_source(documents)
|
|
89
|
+
except DetectionError as exc:
|
|
90
|
+
print(f"redundo adapt: {exc}", file=sys.stderr)
|
|
91
|
+
return 1
|
|
92
|
+
source, reason = detection.source, detection.reason
|
|
93
|
+
|
|
94
|
+
trace_docs = [d for d in documents if is_trace_document(d)]
|
|
95
|
+
log_docs = [d for d in documents if is_log_document(d)]
|
|
96
|
+
|
|
97
|
+
try:
|
|
98
|
+
if source is Source.OPENINFERENCE:
|
|
99
|
+
records, summary = convert_openinference(trace_docs)
|
|
100
|
+
elif source is Source.CLAUDE_CODE:
|
|
101
|
+
records, summary = convert_claude_code(documents)
|
|
102
|
+
elif source is Source.OPENCLAW:
|
|
103
|
+
records, summary = convert_openclaw(trace_docs)
|
|
104
|
+
else:
|
|
105
|
+
records, summary = convert_cowork(log_docs)
|
|
106
|
+
except OtlpParseError as exc:
|
|
107
|
+
print(f"redundo adapt: {exc}", file=sys.stderr)
|
|
108
|
+
return 1
|
|
109
|
+
|
|
110
|
+
if args.output:
|
|
111
|
+
with args.output.open("w", encoding="utf-8") as handle:
|
|
112
|
+
write_jsonl(records, handle)
|
|
113
|
+
else:
|
|
114
|
+
write_jsonl(records, sys.stdout)
|
|
115
|
+
|
|
116
|
+
if args.summary or not records:
|
|
117
|
+
print(f"\nsource: {source.value} (detected via {reason})", file=sys.stderr)
|
|
118
|
+
if skipped_unrecognized:
|
|
119
|
+
print(
|
|
120
|
+
f" ({skipped_unrecognized} file(s) in {args.otlp_dir} were not "
|
|
121
|
+
"recognized OTLP trace/log exports and were ignored)",
|
|
122
|
+
file=sys.stderr,
|
|
123
|
+
)
|
|
124
|
+
skipped_by_kind = getattr(summary, "skipped_by_kind", None)
|
|
125
|
+
if skipped_by_kind:
|
|
126
|
+
for kind, count in sorted(skipped_by_kind.items()):
|
|
127
|
+
print(f" - {count} span(s)/event(s) of kind {kind!r} skipped", file=sys.stderr)
|
|
128
|
+
for note in summary.notes():
|
|
129
|
+
print(f" - {note}", file=sys.stderr)
|
|
130
|
+
|
|
131
|
+
return 0
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
if __name__ == "__main__":
|
|
135
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Minimal local OTLP/HTTP receiver -- point any source's OTLP exporter at
|
|
2
|
+
it and it writes each POST out as OTLP JSON, one file per batch, into one
|
|
3
|
+
output directory that `redundo adapt` reads directly.
|
|
4
|
+
|
|
5
|
+
This is a convenience for local development and one-off analysis, not a
|
|
6
|
+
production observability pipeline -- if you already run a real OTel
|
|
7
|
+
Collector (or any backend with a file/JSON export path), point your
|
|
8
|
+
source at that instead and hand its output directory to `redundo adapt`
|
|
9
|
+
the same way. Every source this project supports needs both `/v1/traces`
|
|
10
|
+
and `/v1/logs` served from the *same* endpoint (some sources use only one,
|
|
11
|
+
some use both, and get one endpoint config to remember either way), so
|
|
12
|
+
this collector always serves both.
|
|
13
|
+
|
|
14
|
+
Requires the `collector` extra: `pip install redundo[collector]`
|
|
15
|
+
|
|
16
|
+
Usage:
|
|
17
|
+
redundo collect --port 4318 --out-dir ./otlp_traces
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import argparse
|
|
23
|
+
import json
|
|
24
|
+
import sys
|
|
25
|
+
import time
|
|
26
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
|
|
29
|
+
try:
|
|
30
|
+
from google.protobuf.json_format import MessageToDict
|
|
31
|
+
from opentelemetry.proto.collector.logs.v1.logs_service_pb2 import (
|
|
32
|
+
ExportLogsServiceRequest,
|
|
33
|
+
ExportLogsServiceResponse,
|
|
34
|
+
)
|
|
35
|
+
from opentelemetry.proto.collector.trace.v1.trace_service_pb2 import (
|
|
36
|
+
ExportTraceServiceRequest,
|
|
37
|
+
ExportTraceServiceResponse,
|
|
38
|
+
)
|
|
39
|
+
except ImportError:
|
|
40
|
+
print(
|
|
41
|
+
"redundo collect requires the 'collector' extra: "
|
|
42
|
+
"pip install redundo[collector]",
|
|
43
|
+
file=sys.stderr,
|
|
44
|
+
)
|
|
45
|
+
raise SystemExit(1)
|
|
46
|
+
|
|
47
|
+
OUT_DIR = Path("./otlp_traces")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _fix_span_ids_to_hex(request: ExportTraceServiceRequest, document: dict) -> None:
|
|
51
|
+
"""MessageToDict base64-encodes `bytes` fields by default. The OTLP
|
|
52
|
+
spec carves out trace_id/span_id/parent_span_id as a documented
|
|
53
|
+
exception -- those are hex in real OTLP JSON, not base64. Overwrite
|
|
54
|
+
them from the original protobuf objects, which have the real bytes.
|
|
55
|
+
"""
|
|
56
|
+
for rs_proto, rs_doc in zip(request.resource_spans, document.get("resourceSpans", [])):
|
|
57
|
+
for ss_proto, ss_doc in zip(rs_proto.scope_spans, rs_doc.get("scopeSpans", [])):
|
|
58
|
+
for span_proto, span_doc in zip(ss_proto.spans, ss_doc.get("spans", [])):
|
|
59
|
+
span_doc["traceId"] = span_proto.trace_id.hex()
|
|
60
|
+
span_doc["spanId"] = span_proto.span_id.hex()
|
|
61
|
+
if span_proto.parent_span_id:
|
|
62
|
+
span_doc["parentSpanId"] = span_proto.parent_span_id.hex()
|
|
63
|
+
elif "parentSpanId" in span_doc:
|
|
64
|
+
del span_doc["parentSpanId"]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _fix_log_ids_to_hex(request: ExportLogsServiceRequest, document: dict) -> None:
|
|
68
|
+
for rl_proto, rl_doc in zip(request.resource_logs, document.get("resourceLogs", [])):
|
|
69
|
+
for sl_proto, sl_doc in zip(rl_proto.scope_logs, rl_doc.get("scopeLogs", [])):
|
|
70
|
+
for rec_proto, rec_doc in zip(sl_proto.log_records, sl_doc.get("logRecords", [])):
|
|
71
|
+
if rec_proto.trace_id:
|
|
72
|
+
rec_doc["traceId"] = rec_proto.trace_id.hex()
|
|
73
|
+
elif "traceId" in rec_doc:
|
|
74
|
+
del rec_doc["traceId"]
|
|
75
|
+
if rec_proto.span_id:
|
|
76
|
+
rec_doc["spanId"] = rec_proto.span_id.hex()
|
|
77
|
+
elif "spanId" in rec_doc:
|
|
78
|
+
del rec_doc["spanId"]
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
class Handler(BaseHTTPRequestHandler):
|
|
82
|
+
def log_message(self, fmt, *args):
|
|
83
|
+
print(f"[collector] {self.address_string()} - {fmt % args}")
|
|
84
|
+
|
|
85
|
+
def do_POST(self):
|
|
86
|
+
if self.path in ("/v1/traces", "/v1/traces/"):
|
|
87
|
+
self._handle_traces()
|
|
88
|
+
elif self.path in ("/v1/logs", "/v1/logs/"):
|
|
89
|
+
self._handle_logs()
|
|
90
|
+
else:
|
|
91
|
+
self.send_response(404)
|
|
92
|
+
self.end_headers()
|
|
93
|
+
|
|
94
|
+
def _handle_traces(self):
|
|
95
|
+
length = int(self.headers.get("Content-Length", 0))
|
|
96
|
+
body = self.rfile.read(length)
|
|
97
|
+
request = ExportTraceServiceRequest()
|
|
98
|
+
request.ParseFromString(body)
|
|
99
|
+
document = MessageToDict(
|
|
100
|
+
request, preserving_proto_field_name=False, use_integers_for_enums=True
|
|
101
|
+
)
|
|
102
|
+
_fix_span_ids_to_hex(request, document)
|
|
103
|
+
|
|
104
|
+
span_count = sum(
|
|
105
|
+
len(scope_span.get("spans", []))
|
|
106
|
+
for rs in document.get("resourceSpans", [])
|
|
107
|
+
for scope_span in rs.get("scopeSpans", [])
|
|
108
|
+
)
|
|
109
|
+
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
|
110
|
+
out_path = OUT_DIR / f"traces-{time.time_ns()}.otlp.json"
|
|
111
|
+
out_path.write_text(json.dumps(document), encoding="utf-8")
|
|
112
|
+
print(f"[collector] wrote {span_count} span(s) -> {out_path}")
|
|
113
|
+
|
|
114
|
+
payload = ExportTraceServiceResponse().SerializeToString()
|
|
115
|
+
self.send_response(200)
|
|
116
|
+
self.send_header("Content-Type", "application/x-protobuf")
|
|
117
|
+
self.send_header("Content-Length", str(len(payload)))
|
|
118
|
+
self.end_headers()
|
|
119
|
+
self.wfile.write(payload)
|
|
120
|
+
|
|
121
|
+
def _handle_logs(self):
|
|
122
|
+
length = int(self.headers.get("Content-Length", 0))
|
|
123
|
+
body = self.rfile.read(length)
|
|
124
|
+
request = ExportLogsServiceRequest()
|
|
125
|
+
request.ParseFromString(body)
|
|
126
|
+
document = MessageToDict(
|
|
127
|
+
request, preserving_proto_field_name=False, use_integers_for_enums=True
|
|
128
|
+
)
|
|
129
|
+
_fix_log_ids_to_hex(request, document)
|
|
130
|
+
|
|
131
|
+
record_count = sum(
|
|
132
|
+
len(scope_log.get("logRecords", []))
|
|
133
|
+
for rl in document.get("resourceLogs", [])
|
|
134
|
+
for scope_log in rl.get("scopeLogs", [])
|
|
135
|
+
)
|
|
136
|
+
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
|
137
|
+
out_path = OUT_DIR / f"logs-{time.time_ns()}.otlp.json"
|
|
138
|
+
out_path.write_text(json.dumps(document), encoding="utf-8")
|
|
139
|
+
print(f"[collector] wrote {record_count} log record(s) -> {out_path}")
|
|
140
|
+
|
|
141
|
+
payload = ExportLogsServiceResponse().SerializeToString()
|
|
142
|
+
self.send_response(200)
|
|
143
|
+
self.send_header("Content-Type", "application/x-protobuf")
|
|
144
|
+
self.send_header("Content-Length", str(len(payload)))
|
|
145
|
+
self.end_headers()
|
|
146
|
+
self.wfile.write(payload)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def main(argv: list[str] | None = None) -> int:
|
|
150
|
+
parser = argparse.ArgumentParser(prog="redundo collect")
|
|
151
|
+
parser.add_argument("--port", type=int, default=4318)
|
|
152
|
+
parser.add_argument("--out-dir", type=Path, default=Path("./otlp_traces"))
|
|
153
|
+
args = parser.parse_args(argv)
|
|
154
|
+
|
|
155
|
+
global OUT_DIR
|
|
156
|
+
OUT_DIR = args.out_dir
|
|
157
|
+
OUT_DIR.mkdir(parents=True, exist_ok=True)
|
|
158
|
+
|
|
159
|
+
server = ThreadingHTTPServer(("localhost", args.port), Handler)
|
|
160
|
+
print(f"[collector] listening on http://localhost:{args.port}/v1/traces and /v1/logs")
|
|
161
|
+
print(f"[collector] writing OTLP JSON batches to {OUT_DIR.resolve()}")
|
|
162
|
+
print("[collector] Ctrl+C to stop, then: redundo adapt <out-dir> -o trace.jsonl")
|
|
163
|
+
try:
|
|
164
|
+
server.serve_forever()
|
|
165
|
+
except KeyboardInterrupt:
|
|
166
|
+
pass
|
|
167
|
+
return 0
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
if __name__ == "__main__":
|
|
171
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""Figure out which source produced a captured OTLP corpus, so the CLI can
|
|
2
|
+
"just point it at your traces directory" without the user having to name
|
|
3
|
+
the source themselves.
|
|
4
|
+
|
|
5
|
+
Detection order, each check grounded in something actually verified
|
|
6
|
+
against real captured data or real source code (see each source's
|
|
7
|
+
docs/*.md), not assumed:
|
|
8
|
+
|
|
9
|
+
1. Any span named `claude_code.*` -> Claude Code. Cowork never produces
|
|
10
|
+
trace spans in its documented configuration, and OpenInference spans
|
|
11
|
+
are never named this way, so this is unambiguous by itself.
|
|
12
|
+
2. Any span named `openclaw.*` -> OpenClaw. Same reasoning: no other
|
|
13
|
+
supported source uses this prefix. This only catches OpenClaw's default
|
|
14
|
+
span naming -- an operator who has opted into
|
|
15
|
+
`OTEL_SEMCONV_STABILITY_OPT_IN=gen_ai_latest_experimental` gets spans
|
|
16
|
+
named `"<gen_ai.operation.name> <model>"` instead, which is genuinely
|
|
17
|
+
ambiguous with other gen_ai-semconv-instrumented sources and isn't
|
|
18
|
+
guessed at here -- see step 3's fallback and docs/openclaw.md.
|
|
19
|
+
3. Any span carrying `openinference.span.kind` -> OpenInference (Hermes,
|
|
20
|
+
or anything else instrumented with an OpenInference-compatible
|
|
21
|
+
library -- see docs/openinference.md for what that covers and doesn't).
|
|
22
|
+
4. Any span carrying `openclaw.model_call.observation_unit` -> OpenClaw.
|
|
23
|
+
Catches the gen_ai_latest_experimental-named case from step 2: this
|
|
24
|
+
attribute is present on every OpenClaw model-call span regardless of
|
|
25
|
+
naming mode (confirmed against the exporter's own source), and no other
|
|
26
|
+
supported source sets it.
|
|
27
|
+
5. Otherwise (a logs-only corpus, no trace spans at all): Claude Code and
|
|
28
|
+
Cowork both emit an overlapping set of logs-signal event names
|
|
29
|
+
(`user_prompt`, `tool_result`, `api_request`, ...), so the event shape
|
|
30
|
+
alone is genuinely ambiguous -- confirmed empirically, not assumed
|
|
31
|
+
(see docs/claude-code.md and docs/cowork.md). Disambiguated by the
|
|
32
|
+
OTLP resource-level `service.name` attribute instead
|
|
33
|
+
(`resource.attributes`, attached once per resourceLogs entry,
|
|
34
|
+
describing the emitting process -- "claude-code" or "cowork",
|
|
35
|
+
confirmed against real captures / the Cowork monitoring reference).
|
|
36
|
+
Falls back to Claude-Code-only event names (`mcp_server_connection`,
|
|
37
|
+
`permission_mode_changed`, `auth` -- not in Cowork's documented event
|
|
38
|
+
list) if `service.name` is ever missing.
|
|
39
|
+
|
|
40
|
+
Detection failure is reported, never guessed past -- see DetectionError.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
from __future__ import annotations
|
|
44
|
+
|
|
45
|
+
from dataclasses import dataclass
|
|
46
|
+
from enum import Enum
|
|
47
|
+
from typing import Any
|
|
48
|
+
|
|
49
|
+
from .otlp import document_resource_attributes, is_log_document, is_trace_document, parse_log_records, parse_spans
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class Source(str, Enum):
|
|
53
|
+
OPENINFERENCE = "openinference"
|
|
54
|
+
CLAUDE_CODE = "claude-code"
|
|
55
|
+
COWORK = "cowork"
|
|
56
|
+
OPENCLAW = "openclaw"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class DetectionError(ValueError):
|
|
60
|
+
pass
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# Event names Cowork's monitoring reference documents in full -- an event
|
|
64
|
+
# outside this set appearing in a logs-only corpus means it isn't Cowork.
|
|
65
|
+
_COWORK_EVENT_NAMES = frozenset(
|
|
66
|
+
{"user_prompt", "assistant_response", "tool_result", "api_request", "api_error", "tool_decision"}
|
|
67
|
+
)
|
|
68
|
+
# Observed on real Claude Code captures, never documented for Cowork.
|
|
69
|
+
_CLAUDE_CODE_ONLY_EVENT_NAMES = frozenset(
|
|
70
|
+
{"mcp_server_connection", "permission_mode_changed", "auth"}
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
_OPENINFERENCE_KIND_ATTR = "openinference.span.kind"
|
|
74
|
+
_OPENCLAW_OBSERVATION_UNIT_ATTR = "openclaw.model_call.observation_unit"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass
|
|
78
|
+
class Detection:
|
|
79
|
+
source: Source
|
|
80
|
+
reason: str
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def detect_source(documents: list[dict[str, Any]]) -> Detection:
|
|
84
|
+
trace_docs = [d for d in documents if is_trace_document(d)]
|
|
85
|
+
log_docs = [d for d in documents if is_log_document(d)]
|
|
86
|
+
|
|
87
|
+
for doc in trace_docs:
|
|
88
|
+
for span in parse_spans(doc):
|
|
89
|
+
if span.name.startswith("claude_code."):
|
|
90
|
+
return Detection(Source.CLAUDE_CODE, f"span name {span.name!r}")
|
|
91
|
+
if span.name.startswith("openclaw."):
|
|
92
|
+
return Detection(Source.OPENCLAW, f"span name {span.name!r}")
|
|
93
|
+
if _OPENINFERENCE_KIND_ATTR in span.attributes:
|
|
94
|
+
return Detection(
|
|
95
|
+
Source.OPENINFERENCE, f"span attribute {_OPENINFERENCE_KIND_ATTR!r}"
|
|
96
|
+
)
|
|
97
|
+
if _OPENCLAW_OBSERVATION_UNIT_ATTR in span.attributes:
|
|
98
|
+
return Detection(
|
|
99
|
+
Source.OPENCLAW, f"span attribute {_OPENCLAW_OBSERVATION_UNIT_ATTR!r}"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
for doc in trace_docs + log_docs:
|
|
103
|
+
for resource_attrs in document_resource_attributes(doc):
|
|
104
|
+
service_name = resource_attrs.get("service.name")
|
|
105
|
+
if service_name == "claude-code":
|
|
106
|
+
return Detection(Source.CLAUDE_CODE, "resource service.name='claude-code'")
|
|
107
|
+
if service_name == "cowork":
|
|
108
|
+
return Detection(Source.COWORK, "resource service.name='cowork'")
|
|
109
|
+
|
|
110
|
+
event_names: set[str] = set()
|
|
111
|
+
for doc in log_docs:
|
|
112
|
+
for rec in parse_log_records(doc):
|
|
113
|
+
name = rec.attributes.get("event.name")
|
|
114
|
+
if isinstance(name, str):
|
|
115
|
+
event_names.add(name)
|
|
116
|
+
if event_names:
|
|
117
|
+
if event_names & _CLAUDE_CODE_ONLY_EVENT_NAMES:
|
|
118
|
+
return Detection(
|
|
119
|
+
Source.CLAUDE_CODE,
|
|
120
|
+
f"event name(s) {sorted(event_names & _CLAUDE_CODE_ONLY_EVENT_NAMES)} "
|
|
121
|
+
"not in Cowork's documented event set",
|
|
122
|
+
)
|
|
123
|
+
if event_names <= _COWORK_EVENT_NAMES:
|
|
124
|
+
return Detection(
|
|
125
|
+
Source.COWORK,
|
|
126
|
+
"logs-only corpus with only Cowork-documented event names, no "
|
|
127
|
+
"service.name and no Claude-Code-only event present",
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
raise DetectionError(
|
|
131
|
+
"could not determine which source produced this corpus -- found "
|
|
132
|
+
f"{len(trace_docs)} trace document(s) and {len(log_docs)} log document(s), "
|
|
133
|
+
"but none carried a recognizable span name, openinference.span.kind "
|
|
134
|
+
"attribute, resource service.name, or logs-signal event.name. Pass "
|
|
135
|
+
"--source explicitly (openinference, claude-code, cowork, or openclaw)."
|
|
136
|
+
)
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
"""Content hashing: the precise procedure, implemented once so it can be
|
|
2
|
+
copied correctly. See docs/hashing.md for the written contract this implements and
|
|
3
|
+
the reasoning behind each choice. Do not change this file's behavior
|
|
4
|
+
without bumping HASH_SPEC -- comparisons across corpora depend on both
|
|
5
|
+
sides having run identical code.
|
|
6
|
+
|
|
7
|
+
The one invariant that matters most: nothing downstream of this module
|
|
8
|
+
ever sees raw content, only hashes. That's what makes it safe to run this
|
|
9
|
+
adapter against production traces without a security review of the
|
|
10
|
+
analyzer. Don't let raw prompt/argument text leak into logs, exceptions,
|
|
11
|
+
or the `name`/`workflow` fields this module doesn't touch.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import hashlib
|
|
17
|
+
import json
|
|
18
|
+
import re
|
|
19
|
+
import unicodedata
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
HASH_SPEC = "v1"
|
|
23
|
+
HASH_LENGTH = 16 # hex chars = 64 bits. See docs/hashing.md for the birthday-bound math.
|
|
24
|
+
|
|
25
|
+
# Order matters: broader/more specific patterns first so a later pattern
|
|
26
|
+
# can't partially match inside a token an earlier pass already replaced.
|
|
27
|
+
_ISO_DATETIME_RE = re.compile(
|
|
28
|
+
r"\b\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?\b"
|
|
29
|
+
)
|
|
30
|
+
_ISO_DATE_ONLY_RE = re.compile(r"\b\d{4}-\d{2}-\d{2}\b")
|
|
31
|
+
_RFC_DATE_RE = re.compile(
|
|
32
|
+
r"\b(?:Mon|Tue|Wed|Thu|Fri|Sat|Sun),\s+\d{1,2}\s+"
|
|
33
|
+
r"(?:Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)\w*\s+\d{4}\s+"
|
|
34
|
+
r"\d{2}:\d{2}:\d{2}\s+(?:GMT|UTC|[+-]\d{4})\b"
|
|
35
|
+
)
|
|
36
|
+
_UUID_RE = re.compile(
|
|
37
|
+
r"\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b"
|
|
38
|
+
)
|
|
39
|
+
_DURATION_RE = re.compile(r"\b\d+(?:\.\d+)?(?:ms|us|µs|ns|s|m|h)\b")
|
|
40
|
+
_HEX_ADDR_RE = re.compile(r"\b0x[0-9a-fA-F]{4,}\b")
|
|
41
|
+
_TMP_PATH_RE = re.compile(r"(?:/tmp/|/var/folders/)\S*")
|
|
42
|
+
_BARE_INTEGER_RE = re.compile(r"\b\d{5,}\b") # opt-in only; see mask_volatile
|
|
43
|
+
|
|
44
|
+
# (replacement token, pattern) in application order.
|
|
45
|
+
_MASKS: list[tuple[str, re.Pattern[str]]] = [
|
|
46
|
+
("<DATE>", _ISO_DATETIME_RE),
|
|
47
|
+
("<DATE>", _ISO_DATE_ONLY_RE),
|
|
48
|
+
("<DATE>", _RFC_DATE_RE),
|
|
49
|
+
("<UUID>", _UUID_RE),
|
|
50
|
+
("<DUR>", _DURATION_RE),
|
|
51
|
+
("<ADDR>", _HEX_ADDR_RE),
|
|
52
|
+
("<TMP>", _TMP_PATH_RE),
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def normalize_text(text: str) -> str:
|
|
57
|
+
"""Unicode NFC, collapse whitespace runs, strip ends. For plain-text
|
|
58
|
+
content (prompts, messages) -- not for structured content, which goes
|
|
59
|
+
through canonicalize_json instead.
|
|
60
|
+
"""
|
|
61
|
+
text = unicodedata.normalize("NFC", text)
|
|
62
|
+
text = re.sub(r"\s+", " ", text)
|
|
63
|
+
return text.strip()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _normalize_numbers(value: Any) -> Any:
|
|
67
|
+
"""Floats with no fractional part collapse to int, so 1 and 1.0
|
|
68
|
+
canonicalize identically. This is the one deliberate departure from
|
|
69
|
+
"serialize exactly what json.loads gave you" -- documented in docs/hashing.md.
|
|
70
|
+
"""
|
|
71
|
+
if isinstance(value, float) and value.is_integer():
|
|
72
|
+
return int(value)
|
|
73
|
+
if isinstance(value, dict):
|
|
74
|
+
return {k: _normalize_numbers(v) for k, v in value.items()}
|
|
75
|
+
if isinstance(value, list):
|
|
76
|
+
return [_normalize_numbers(v) for v in value]
|
|
77
|
+
return value
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def canonicalize_json(value: Any) -> str:
|
|
81
|
+
"""Sorted keys, no insignificant whitespace, normalized numbers.
|
|
82
|
+
`value` may already be a parsed object, or a JSON string -- both are
|
|
83
|
+
accepted so callers don't need to know which they have.
|
|
84
|
+
"""
|
|
85
|
+
if isinstance(value, str):
|
|
86
|
+
value = json.loads(value)
|
|
87
|
+
normalized = _normalize_numbers(value)
|
|
88
|
+
return json.dumps(normalized, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def mask_volatile(text: str, *, mask_integers: bool = False) -> tuple[str, int]:
|
|
92
|
+
"""Replace volatile spans with stable placeholder tokens. Returns
|
|
93
|
+
(masked_text, spans_masked) -- the count is the diagnostic signal:
|
|
94
|
+
surface it in metadata so "zero repeats" and "zero repeats because
|
|
95
|
+
something volatile leaked through" don't look identical.
|
|
96
|
+
|
|
97
|
+
mask_integers is opt-in and off by default: a bare integer could be an
|
|
98
|
+
epoch timestamp or an order ID, and masking order IDs would collapse
|
|
99
|
+
genuinely different calls into one hash -- a false positive in the
|
|
100
|
+
headline metric, which is worse than a false negative here.
|
|
101
|
+
"""
|
|
102
|
+
total = 0
|
|
103
|
+
for token, pattern in _MASKS:
|
|
104
|
+
text, n = pattern.subn(token, text)
|
|
105
|
+
total += n
|
|
106
|
+
if mask_integers:
|
|
107
|
+
text, n = _BARE_INTEGER_RE.subn("<NUM>", text)
|
|
108
|
+
total += n
|
|
109
|
+
return text, total
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def content_hash(
|
|
113
|
+
raw: Any,
|
|
114
|
+
*,
|
|
115
|
+
structured: bool = False,
|
|
116
|
+
mask_integers: bool = False,
|
|
117
|
+
) -> tuple[str, int]:
|
|
118
|
+
"""The full procedure: normalize (text) or canonicalize (structured),
|
|
119
|
+
mask volatile spans, hash. Returns (hash_hex, masked_span_count).
|
|
120
|
+
|
|
121
|
+
`structured=True` for tool call arguments / JSON payloads.
|
|
122
|
+
`structured=False` (default) for free-text prompts and messages.
|
|
123
|
+
|
|
124
|
+
A `structured=True` value that isn't actually valid JSON (a mime_type
|
|
125
|
+
attribute claiming application/json on malformed content, for
|
|
126
|
+
instance) falls back to plain-text normalization instead of raising.
|
|
127
|
+
This adapter runs inside the boundary that's supposed to keep raw
|
|
128
|
+
content contained; letting one malformed record's parse error
|
|
129
|
+
propagate as an uncaught exception is the wrong failure mode here --
|
|
130
|
+
it aborts the whole conversion, and an exception object risks
|
|
131
|
+
carrying a fragment of that content into a log or traceback somewhere
|
|
132
|
+
outside this function's control. Degrading to text-mode hashing
|
|
133
|
+
keeps every record processed and keeps content from ever needing to
|
|
134
|
+
leave this function in the first place.
|
|
135
|
+
"""
|
|
136
|
+
if structured:
|
|
137
|
+
try:
|
|
138
|
+
text = canonicalize_json(raw)
|
|
139
|
+
except (TypeError, ValueError):
|
|
140
|
+
text = normalize_text(str(raw))
|
|
141
|
+
else:
|
|
142
|
+
text = normalize_text(str(raw))
|
|
143
|
+
masked_text, span_count = mask_volatile(text, mask_integers=mask_integers)
|
|
144
|
+
digest = hashlib.sha256(masked_text.encode("utf-8")).hexdigest()[:HASH_LENGTH]
|
|
145
|
+
return digest, span_count
|