induscode 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.
- induscode/__init__.py +56 -0
- induscode/addons/__init__.py +176 -0
- induscode/addons/contract.py +923 -0
- induscode/addons/dispatch/__init__.py +43 -0
- induscode/addons/dispatch/event_dispatcher.py +348 -0
- induscode/addons/dispatch/tool_interceptor.py +349 -0
- induscode/addons/host.py +469 -0
- induscode/addons/loader.py +314 -0
- induscode/addons/manifest.py +232 -0
- induscode/addons/surface.py +199 -0
- induscode/boot/__init__.py +108 -0
- induscode/boot/auth_vault.py +323 -0
- induscode/boot/boot.py +210 -0
- induscode/boot/contract.py +223 -0
- induscode/boot/invocation.py +117 -0
- induscode/boot/runners/__init__.py +42 -0
- induscode/boot/runners/link_runner.py +82 -0
- induscode/boot/runners/oneshot_runner.py +85 -0
- induscode/boot/runners/registry.py +46 -0
- induscode/boot/runners/repl_runner.py +340 -0
- induscode/boot/runners/session.py +549 -0
- induscode/boot/stages.py +198 -0
- induscode/boot/upgrade/__init__.py +36 -0
- induscode/boot/upgrade/apply.py +125 -0
- induscode/boot/upgrade/upgrades.py +136 -0
- induscode/briefing/__init__.py +115 -0
- induscode/briefing/compose.py +414 -0
- induscode/briefing/contract.py +528 -0
- induscode/briefing/macros.py +721 -0
- induscode/briefing/skills.py +417 -0
- induscode/capability_deck/__init__.py +233 -0
- induscode/capability_deck/bridge_ledger/__init__.py +66 -0
- induscode/capability_deck/bridge_ledger/key.py +181 -0
- induscode/capability_deck/bridge_ledger/ledger.py +276 -0
- induscode/capability_deck/bridge_ledger/network.py +336 -0
- induscode/capability_deck/builtin_bridge.py +358 -0
- induscode/capability_deck/cards/__init__.py +116 -0
- induscode/capability_deck/cards/bg_process.py +482 -0
- induscode/capability_deck/cards/memory.py +226 -0
- induscode/capability_deck/cards/saas.py +280 -0
- induscode/capability_deck/cards/task.py +256 -0
- induscode/capability_deck/cards/todo.py +312 -0
- induscode/capability_deck/contract.py +450 -0
- induscode/capability_deck/manifest.py +126 -0
- induscode/capability_deck/provision.py +217 -0
- induscode/channels/__init__.py +146 -0
- induscode/channels/contract.py +585 -0
- induscode/channels/framer.py +132 -0
- induscode/channels/link/__init__.py +50 -0
- induscode/channels/link/dialog.py +246 -0
- induscode/channels/link/driver.py +308 -0
- induscode/channels/link/server.py +217 -0
- induscode/channels/oneshot.py +178 -0
- induscode/channels/ops.py +140 -0
- induscode/channels/session_ops.py +172 -0
- induscode/conductor/__init__.py +240 -0
- induscode/conductor/catalog.py +309 -0
- induscode/conductor/conductor.py +1084 -0
- induscode/conductor/contract.py +1035 -0
- induscode/conductor/matcher.py +291 -0
- induscode/conductor/serialize.py +575 -0
- induscode/conductor/signal_hub.py +382 -0
- induscode/conductor/skill_parse.py +294 -0
- induscode/conductor/transcript_store.py +449 -0
- induscode/console/__init__.py +236 -0
- induscode/console/app.py +1677 -0
- induscode/console/components/__init__.py +62 -0
- induscode/console/components/banner.py +499 -0
- induscode/console/components/banner_sweep.py +188 -0
- induscode/console/components/emblem.py +181 -0
- induscode/console/components/status_bar.py +102 -0
- induscode/console/contract.py +836 -0
- induscode/console/input/__init__.py +107 -0
- induscode/console/input/chord.py +197 -0
- induscode/console/input/dir_reader.py +113 -0
- induscode/console/input/intents.py +258 -0
- induscode/console/input/providers.py +469 -0
- induscode/console/mount.py +137 -0
- induscode/console/overlays/__init__.py +94 -0
- induscode/console/overlays/auth.py +503 -0
- induscode/console/overlays/pickers.py +526 -0
- induscode/console/overlays/router.py +129 -0
- induscode/console/overlays/sessions.py +232 -0
- induscode/console/reducer.py +145 -0
- induscode/console/resume_picker.py +156 -0
- induscode/console/slash_commands/__init__.py +78 -0
- induscode/console/slash_commands/builtins.py +254 -0
- induscode/console/slash_commands/dynamic.py +217 -0
- induscode/console/slash_commands/integrations.py +949 -0
- induscode/console/slash_commands/transcript.py +404 -0
- induscode/console/slash_commands/workbench.py +430 -0
- induscode/console/startup.py +434 -0
- induscode/console/theme/__init__.py +44 -0
- induscode/console/theme/adapter.py +168 -0
- induscode/console/theme/palette.py +128 -0
- induscode/console/theme/resolve.py +123 -0
- induscode/console/theme/tokens.py +185 -0
- induscode/console_slash/__init__.py +111 -0
- induscode/console_slash/contract.py +185 -0
- induscode/console_slash/registry.py +140 -0
- induscode/console_slash/resolve.py +194 -0
- induscode/console_slash/shared.py +172 -0
- induscode/entry.py +108 -0
- induscode/insight/__init__.py +153 -0
- induscode/insight/collector.py +73 -0
- induscode/insight/replay.py +305 -0
- induscode/insight/wrapper.py +1115 -0
- induscode/kit/__init__.py +82 -0
- induscode/kit/clipboard_image.py +215 -0
- induscode/kit/external_editor.py +120 -0
- induscode/kit/image.py +188 -0
- induscode/kit/shell.py +89 -0
- induscode/kit/tool_fetch.py +288 -0
- induscode/launch/__init__.py +224 -0
- induscode/launch/catalog.py +310 -0
- induscode/launch/contract.py +569 -0
- induscode/launch/credentials.py +852 -0
- induscode/launch/invocation/__init__.py +39 -0
- induscode/launch/invocation/attachments.py +281 -0
- induscode/launch/invocation/flags.py +210 -0
- induscode/launch/invocation/read.py +369 -0
- induscode/launch/invocation/usage.py +110 -0
- induscode/launch/oauth.py +808 -0
- induscode/launch/packages.py +299 -0
- induscode/launch/pickers.py +291 -0
- induscode/py.typed +0 -0
- induscode/runtime_bridge/__init__.py +166 -0
- induscode/runtime_bridge/bridges/__init__.py +66 -0
- induscode/runtime_bridge/bridges/_drive.py +268 -0
- induscode/runtime_bridge/bridges/builtins.py +177 -0
- induscode/runtime_bridge/bridges/claude_cli.py +198 -0
- induscode/runtime_bridge/bridges/codex_cli.py +203 -0
- induscode/runtime_bridge/bridges/indusagi_cli.py +217 -0
- induscode/runtime_bridge/broker.py +397 -0
- induscode/runtime_bridge/contract.py +734 -0
- induscode/runtime_bridge/sink.py +351 -0
- induscode/sessions/__init__.py +25 -0
- induscode/sessions/contract.py +119 -0
- induscode/sessions/library.py +350 -0
- induscode/settings/__init__.py +47 -0
- induscode/settings/contract.py +313 -0
- induscode/settings/manager.py +268 -0
- induscode/transcript_export/__init__.py +109 -0
- induscode/transcript_export/contract.py +522 -0
- induscode/transcript_export/publish.py +455 -0
- induscode/transcript_export/sgr.py +566 -0
- induscode/transcript_export/template.py +319 -0
- induscode/transcript_export/theme_bridge.py +325 -0
- induscode/window_budget/__init__.py +76 -0
- induscode/window_budget/budget/__init__.py +26 -0
- induscode/window_budget/budget/estimate.py +273 -0
- induscode/window_budget/budget/gate.py +60 -0
- induscode/window_budget/budget/slice.py +145 -0
- induscode/window_budget/condenser.py +170 -0
- induscode/window_budget/contract.py +329 -0
- induscode/window_budget/summarize/__init__.py +33 -0
- induscode/window_budget/summarize/condense.py +212 -0
- induscode/window_budget/summarize/prompt.py +241 -0
- induscode/workspace/__init__.py +30 -0
- induscode/workspace/brand.py +96 -0
- induscode/workspace/locator.py +269 -0
- induscode-0.1.0.dist-info/METADATA +97 -0
- induscode-0.1.0.dist-info/RECORD +167 -0
- induscode-0.1.0.dist-info/WHEEL +4 -0
- induscode-0.1.0.dist-info/entry_points.txt +3 -0
- induscode-0.1.0.dist-info/licenses/CREDITS.md +22 -0
- induscode-0.1.0.dist-info/licenses/NOTICE +7 -0
|
@@ -0,0 +1,1115 @@
|
|
|
1
|
+
"""Insight wrapper — the app's tracing vocabulary aliased onto ``indusagi.tracing``.
|
|
2
|
+
|
|
3
|
+
This is a **wrapper, not a re-port** (locked plan decision; PYTHON_PORT_PLAN
|
|
4
|
+
PLAN.md M1 + analysis 05 §3). The TS ``src/insight/`` subsystem maps almost
|
|
5
|
+
1:1 onto the framework's ``indusagi.tracing`` module — same architecture,
|
|
6
|
+
renamed vocabulary — so this module only translates names and pins the app's
|
|
7
|
+
own constants on top of the framework machinery:
|
|
8
|
+
|
|
9
|
+
============================== ==============================================
|
|
10
|
+
app (TS insight) framework (``indusagi.tracing``)
|
|
11
|
+
============================== ==============================================
|
|
12
|
+
``Trail`` / ``TrailId`` trace / ``fresh_trace_id`` (32-hex, 128-bit)
|
|
13
|
+
``Probe`` / ``ProbeId`` ``Segment`` / ``fresh_segment_id`` (16-hex)
|
|
14
|
+
``ProbeKind`` ``SegmentKind`` via :data:`KIND_TO_SEGMENT`
|
|
15
|
+
(run→run, turn→inference, tool→action,
|
|
16
|
+
model→recall, custom→custom)
|
|
17
|
+
``ProbeHandle`` + NOOP_HANDLE ``SegmentHandle`` shape (note/child/fail/close)
|
|
18
|
+
``createRecorder`` :class:`InsightRecorder` over the framework
|
|
19
|
+
``SignalChannel`` + ``SampleGate`` + segment
|
|
20
|
+
value functions (``open_segment`` /
|
|
21
|
+
``with_attributes`` / ``close_segment``)
|
|
22
|
+
``ratioGate`` (FNV-1a) ``SampleGate(RatioStrategy)`` — **verified**
|
|
23
|
+
bit-identical to the TS ``hash32`` for fixture
|
|
24
|
+
trail ids (``"a"*32`` → 1297108005,
|
|
25
|
+
``"b"*32`` → 2615884229), so the framework
|
|
26
|
+
gate is reused, not re-ported
|
|
27
|
+
``SecretRedactor`` re-ported wrapper-side (TS ``createRedactor``)
|
|
28
|
+
reusing the app's ``SecretPattern`` rule set and
|
|
29
|
+
the app token ``"[insight:scrubbed]"``: key rules
|
|
30
|
+
tokenize a whole subtree, value rules replace
|
|
31
|
+
only the matched run (``re.sub``), ``omit_keys``
|
|
32
|
+
drops keys wholesale, and the 4096-char cap is
|
|
33
|
+
applied in the same walk. (Not the framework
|
|
34
|
+
``SecretScrubber``, which replaces a matching
|
|
35
|
+
value wholesale and has no cap or omit support.)
|
|
36
|
+
============================== ==============================================
|
|
37
|
+
|
|
38
|
+
**What rides the wire.** Live probe handles emit *framework* signals
|
|
39
|
+
(``OpenSignal(segment)`` / ``UpdateSignal(id, attributes)`` /
|
|
40
|
+
``CloseSignal(record)``) onto a *framework* ``SignalChannel`` with framework
|
|
41
|
+
segment kinds — so the framework sinks (``ConsoleSink`` / ``FileSink`` /
|
|
42
|
+
``StreamSink``) plug in unchanged and the on-disk NDJSON is the framework's
|
|
43
|
+
flat camelCase close-record schema (see ``replay.py`` for the schema note).
|
|
44
|
+
The app vocabulary lives at the edges: :meth:`InsightRecorder.signals` adapts
|
|
45
|
+
the channel into app-vocabulary :data:`Signal` values, and the replay readers
|
|
46
|
+
recover app :class:`Probe` values from the framework NDJSON.
|
|
47
|
+
|
|
48
|
+
**Remaining divergences from the TS surface** (wrapper costs, all unobserved
|
|
49
|
+
by the ported test suite):
|
|
50
|
+
|
|
51
|
+
- the framework ``UpdateSignal`` carries only ``(id, attributes)`` — not the
|
|
52
|
+
full probe — so the :meth:`InsightRecorder.signals` adapter reconstructs the
|
|
53
|
+
updated probe statefully and stamps the update's ``at`` at observation time;
|
|
54
|
+
- scrubbing happens on *input* (open/child/note attributes, fail text) rather
|
|
55
|
+
than at emit time — equivalent observable behaviour, since attributes only
|
|
56
|
+
enter through those calls.
|
|
57
|
+
|
|
58
|
+
**Behaviours brought to TS parity** (formerly divergences):
|
|
59
|
+
|
|
60
|
+
- ``fail()`` now emits an in-flight ``update`` (mirroring the TS recorder),
|
|
61
|
+
carrying the failure through the reserved fault attributes (see
|
|
62
|
+
:data:`FAULT_MESSAGE_KEY` / :data:`FAULT_NAME_KEY` / :data:`FAULT_STACK_KEY`)
|
|
63
|
+
because the framework ``UpdateSignal`` cannot carry a status/fault directly;
|
|
64
|
+
the ``signals()`` adapter lifts them back into an error-status probe;
|
|
65
|
+
- the fault's error ``name`` and ``stack`` now survive emit + NDJSON + replay
|
|
66
|
+
(via the same reserved attributes), not just the ``message`` the framework
|
|
67
|
+
``SegmentError`` keeps;
|
|
68
|
+
- a string *value* that trips a value rule now has only the matched run(s)
|
|
69
|
+
replaced (TS ``applyValueRules`` semantics), not the whole value; the
|
|
70
|
+
redactor also honours ``omit_keys``.
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
from __future__ import annotations
|
|
74
|
+
|
|
75
|
+
import asyncio
|
|
76
|
+
import math
|
|
77
|
+
import re
|
|
78
|
+
import time
|
|
79
|
+
import traceback
|
|
80
|
+
from collections.abc import Callable, Iterable, Mapping
|
|
81
|
+
from dataclasses import dataclass
|
|
82
|
+
from types import MappingProxyType
|
|
83
|
+
from typing import AsyncIterator, ClassVar, Literal, Protocol, TypeAlias
|
|
84
|
+
|
|
85
|
+
from indusagi.tracing.channel.signal import (
|
|
86
|
+
CloseSignal as FwCloseSignal,
|
|
87
|
+
OpenSignal as FwOpenSignal,
|
|
88
|
+
SignalChannel,
|
|
89
|
+
TraceSignal,
|
|
90
|
+
UpdateSignal as FwUpdateSignal,
|
|
91
|
+
)
|
|
92
|
+
from indusagi.tracing.recorder.sampling import RatioStrategy, SampleGate
|
|
93
|
+
from indusagi.tracing.redaction.secret_scrubber import SecretPattern
|
|
94
|
+
from indusagi.tracing.signal.segment import (
|
|
95
|
+
OpenSegmentInput,
|
|
96
|
+
Segment,
|
|
97
|
+
SegmentError,
|
|
98
|
+
SegmentKind,
|
|
99
|
+
close_segment,
|
|
100
|
+
fresh_segment_id,
|
|
101
|
+
fresh_trace_id,
|
|
102
|
+
open_segment,
|
|
103
|
+
with_attributes,
|
|
104
|
+
)
|
|
105
|
+
from indusagi.tracing.sinks.base import Sink
|
|
106
|
+
|
|
107
|
+
__all__ = [
|
|
108
|
+
"APP_SECRET_PATTERNS",
|
|
109
|
+
"CloseSignal",
|
|
110
|
+
"DEFAULT_MAX_STRING_LENGTH",
|
|
111
|
+
"DEFAULT_REDACTOR",
|
|
112
|
+
"ID_WIDTHS",
|
|
113
|
+
"InsightRecorder",
|
|
114
|
+
"KIND_TO_SEGMENT",
|
|
115
|
+
"NOOP_HANDLE",
|
|
116
|
+
"OpenSignal",
|
|
117
|
+
"PASSTHRU_REDACTOR",
|
|
118
|
+
"PROBE_KINDS",
|
|
119
|
+
"Probe",
|
|
120
|
+
"ProbeAttributes",
|
|
121
|
+
"ProbeFault",
|
|
122
|
+
"ProbeHandle",
|
|
123
|
+
"ProbeId",
|
|
124
|
+
"ProbeKind",
|
|
125
|
+
"ProbeOutcome",
|
|
126
|
+
"ProbeStatus",
|
|
127
|
+
"REDACTED_TOKEN",
|
|
128
|
+
"RatioGate",
|
|
129
|
+
"SEGMENT_TO_KIND",
|
|
130
|
+
"SecretRedactor",
|
|
131
|
+
"Signal",
|
|
132
|
+
"SignalPhase",
|
|
133
|
+
"TRUNCATION_SUFFIX",
|
|
134
|
+
"TrailId",
|
|
135
|
+
"TrailRecorder",
|
|
136
|
+
"UpdateSignal",
|
|
137
|
+
"always_gate",
|
|
138
|
+
"create_recorder",
|
|
139
|
+
"create_redactor",
|
|
140
|
+
"fault_of",
|
|
141
|
+
"is_close_signal",
|
|
142
|
+
"is_closed_probe",
|
|
143
|
+
"mint_probe_id",
|
|
144
|
+
"mint_trail_id",
|
|
145
|
+
"never_gate",
|
|
146
|
+
"probe_from_segment",
|
|
147
|
+
"ratio_gate",
|
|
148
|
+
]
|
|
149
|
+
|
|
150
|
+
# --------------------------------------------------------------------------
|
|
151
|
+
# Identifiers + kind vocabulary
|
|
152
|
+
# --------------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
# Lowercase hex ids at the W3C Trace Context widths (TS contract ID_WIDTHS).
|
|
155
|
+
TrailId: TypeAlias = str
|
|
156
|
+
ProbeId: TypeAlias = str
|
|
157
|
+
|
|
158
|
+
# The byte widths the id minter uses (trail: 32 hex chars, probe: 16).
|
|
159
|
+
ID_WIDTHS: Mapping[str, int] = MappingProxyType({"trail": 16, "probe": 8})
|
|
160
|
+
|
|
161
|
+
# The app's probe kinds — a small, closed set chosen for a coding agent.
|
|
162
|
+
ProbeKind: TypeAlias = Literal["run", "turn", "tool", "model", "custom"]
|
|
163
|
+
PROBE_KINDS: tuple[ProbeKind, ...] = ("run", "turn", "tool", "model", "custom")
|
|
164
|
+
|
|
165
|
+
ProbeStatus: TypeAlias = Literal["open", "ok", "error"]
|
|
166
|
+
ProbeOutcome: TypeAlias = Literal["ok", "error"]
|
|
167
|
+
ProbeAttributes: TypeAlias = Mapping[str, object]
|
|
168
|
+
|
|
169
|
+
# The locked kind map: app probe kind ⇄ framework segment kind.
|
|
170
|
+
KIND_TO_SEGMENT: Mapping[ProbeKind, SegmentKind] = MappingProxyType(
|
|
171
|
+
{
|
|
172
|
+
"run": "run",
|
|
173
|
+
"turn": "inference",
|
|
174
|
+
"tool": "action",
|
|
175
|
+
"model": "recall",
|
|
176
|
+
"custom": "custom",
|
|
177
|
+
}
|
|
178
|
+
)
|
|
179
|
+
SEGMENT_TO_KIND: Mapping[SegmentKind, ProbeKind] = MappingProxyType(
|
|
180
|
+
{segment: kind for kind, segment in KIND_TO_SEGMENT.items()}
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def mint_trail_id() -> TrailId:
|
|
185
|
+
"""Mint a fresh 128-bit trail id (32 hex chars) via the framework minter."""
|
|
186
|
+
return fresh_trace_id()
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def mint_probe_id() -> ProbeId:
|
|
190
|
+
"""Mint a fresh 64-bit probe id (16 hex chars) via the framework minter."""
|
|
191
|
+
return fresh_segment_id()
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _now_ms() -> int:
|
|
195
|
+
return time.time_ns() // 1_000_000
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
_EMPTY_ATTRIBUTES: ProbeAttributes = MappingProxyType({})
|
|
199
|
+
|
|
200
|
+
# --------------------------------------------------------------------------
|
|
201
|
+
# Probe data model (app-vocabulary view over the framework Segment)
|
|
202
|
+
# --------------------------------------------------------------------------
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
@dataclass(frozen=True, slots=True)
|
|
206
|
+
class ProbeFault:
|
|
207
|
+
"""A captured failure attached to a probe (message + optional name/stack).
|
|
208
|
+
|
|
209
|
+
Richer than the framework's message-only ``SegmentError`` — the TS
|
|
210
|
+
``ProbeFault`` carries the error name and stack alongside the message. The
|
|
211
|
+
framework ``SegmentError`` keeps only ``message`` and its NDJSON sink only
|
|
212
|
+
writes ``{"message": …}``, so the wrapper carries ``name``/``stack`` through
|
|
213
|
+
the *attribute* channel instead (see :data:`FAULT_NAME_KEY` /
|
|
214
|
+
:data:`FAULT_STACK_KEY`), recovering them on the close signal and on replay.
|
|
215
|
+
"""
|
|
216
|
+
|
|
217
|
+
message: str
|
|
218
|
+
name: str | None = None
|
|
219
|
+
stack: str | None = None
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
# Reserved attribute keys the wrapper uses to carry a fault's fields through the
|
|
223
|
+
# framework's message-only ``SegmentError`` / ``UpdateSignal`` paths. The
|
|
224
|
+
# framework scrubber's key rules never match them (they contain none of the
|
|
225
|
+
# secret-key fragments), the file sink serializes them like any other attribute,
|
|
226
|
+
# and the close-signal adapter / replay reader lift them back onto the recovered
|
|
227
|
+
# :class:`ProbeFault`. ``FAULT_MESSAGE_KEY`` rides only the in-flight ``fail()``
|
|
228
|
+
# update (the close record keeps the message on ``SegmentError``); name/stack
|
|
229
|
+
# ride both update and close. The dotted ``insight.`` namespace avoids collision
|
|
230
|
+
# with caller attributes.
|
|
231
|
+
FAULT_MESSAGE_KEY = "insight.fault.message"
|
|
232
|
+
FAULT_NAME_KEY = "insight.fault.name"
|
|
233
|
+
FAULT_STACK_KEY = "insight.fault.stack"
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def fault_of(error: object) -> ProbeFault:
|
|
237
|
+
"""Coerce an arbitrary raised value into a serializable :class:`ProbeFault`.
|
|
238
|
+
|
|
239
|
+
An exception contributes its message, type name, and a rendered traceback
|
|
240
|
+
when one is attached; any other value is rendered to a string message.
|
|
241
|
+
"""
|
|
242
|
+
if isinstance(error, BaseException):
|
|
243
|
+
stack: str | None = None
|
|
244
|
+
if error.__traceback__ is not None:
|
|
245
|
+
stack = "".join(traceback.format_exception(error)).rstrip("\n")
|
|
246
|
+
return ProbeFault(message=str(error), name=type(error).__name__, stack=stack)
|
|
247
|
+
return ProbeFault(message=str(error))
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
def _fault_attributes(fault: ProbeFault) -> dict[str, object]:
|
|
251
|
+
"""The reserved name/stack attributes carrying a fault's rich fields.
|
|
252
|
+
|
|
253
|
+
Only the present (non-``None``) fields are emitted, so a bare
|
|
254
|
+
message-only fault adds nothing to the attribute bag.
|
|
255
|
+
"""
|
|
256
|
+
extra: dict[str, object] = {}
|
|
257
|
+
if fault.name is not None:
|
|
258
|
+
extra[FAULT_NAME_KEY] = fault.name
|
|
259
|
+
if fault.stack is not None:
|
|
260
|
+
extra[FAULT_STACK_KEY] = fault.stack
|
|
261
|
+
return extra
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _fault_from_record(
|
|
265
|
+
message: str | None, attributes: Mapping[str, object]
|
|
266
|
+
) -> ProbeFault | None:
|
|
267
|
+
"""Recover a :class:`ProbeFault` from a message plus the reserved attributes.
|
|
268
|
+
|
|
269
|
+
Lifts ``name``/``stack`` out of the reserved attribute keys (the channel
|
|
270
|
+
the wrapper uses to carry them past the framework's message-only error).
|
|
271
|
+
Returns ``None`` when there is no message *and* no reserved fault attribute.
|
|
272
|
+
"""
|
|
273
|
+
raw_name = attributes.get(FAULT_NAME_KEY)
|
|
274
|
+
raw_stack = attributes.get(FAULT_STACK_KEY)
|
|
275
|
+
name = raw_name if isinstance(raw_name, str) else None
|
|
276
|
+
stack = raw_stack if isinstance(raw_stack, str) else None
|
|
277
|
+
if message is None and name is None and stack is None:
|
|
278
|
+
return None
|
|
279
|
+
return ProbeFault(message=message if message is not None else "", name=name, stack=stack)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
@dataclass(frozen=True, slots=True)
|
|
283
|
+
class Probe:
|
|
284
|
+
"""An immutable timed segment within a trail — the app view of a ``Segment``.
|
|
285
|
+
|
|
286
|
+
Every transition produces a *new* frozen value; ``ended_at is None`` means
|
|
287
|
+
the probe is still in flight.
|
|
288
|
+
"""
|
|
289
|
+
|
|
290
|
+
id: ProbeId
|
|
291
|
+
trail_id: TrailId
|
|
292
|
+
parent_id: ProbeId | None
|
|
293
|
+
kind: ProbeKind
|
|
294
|
+
name: str
|
|
295
|
+
started_at: int
|
|
296
|
+
ended_at: int | None = None
|
|
297
|
+
status: ProbeStatus = "open"
|
|
298
|
+
attributes: ProbeAttributes = _EMPTY_ATTRIBUTES
|
|
299
|
+
fault: ProbeFault | None = None
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def is_closed_probe(probe: Probe) -> bool:
|
|
303
|
+
"""Whether ``probe`` reached a terminal state (timed and not ``open``)."""
|
|
304
|
+
return probe.status != "open" and probe.ended_at is not None
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def probe_from_segment(segment: Segment, fault: ProbeFault | None = None) -> Probe:
|
|
308
|
+
"""Project a framework ``Segment`` onto the app's :class:`Probe` view.
|
|
309
|
+
|
|
310
|
+
Maps the framework segment kind back to the app vocabulary; ``fault``
|
|
311
|
+
overrides the fault otherwise recovered from ``segment.error`` plus the
|
|
312
|
+
reserved name/stack attributes. The reserved fault keys are stripped from
|
|
313
|
+
the exposed attribute bag so the fault's name/stack surface only on
|
|
314
|
+
:attr:`Probe.fault`, never as caller attributes.
|
|
315
|
+
"""
|
|
316
|
+
if fault is None:
|
|
317
|
+
message = segment.error.message if segment.error is not None else None
|
|
318
|
+
fault = _fault_from_record(message, segment.attributes)
|
|
319
|
+
attributes: ProbeAttributes = segment.attributes
|
|
320
|
+
if FAULT_NAME_KEY in attributes or FAULT_STACK_KEY in attributes:
|
|
321
|
+
attributes = MappingProxyType(
|
|
322
|
+
{
|
|
323
|
+
k: v
|
|
324
|
+
for k, v in attributes.items()
|
|
325
|
+
if k not in (FAULT_NAME_KEY, FAULT_STACK_KEY)
|
|
326
|
+
}
|
|
327
|
+
)
|
|
328
|
+
return Probe(
|
|
329
|
+
id=segment.id,
|
|
330
|
+
trail_id=segment.trace_id,
|
|
331
|
+
parent_id=segment.parent_id,
|
|
332
|
+
kind=SEGMENT_TO_KIND[segment.kind],
|
|
333
|
+
name=segment.name,
|
|
334
|
+
started_at=segment.started_at,
|
|
335
|
+
ended_at=segment.ended_at,
|
|
336
|
+
status=segment.status,
|
|
337
|
+
attributes=attributes,
|
|
338
|
+
fault=fault,
|
|
339
|
+
)
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
# --------------------------------------------------------------------------
|
|
343
|
+
# Signal — the app-vocabulary wire union
|
|
344
|
+
# --------------------------------------------------------------------------
|
|
345
|
+
|
|
346
|
+
SignalPhase: TypeAlias = Literal["open", "update", "close"]
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
@dataclass(frozen=True, slots=True)
|
|
350
|
+
class OpenSignal:
|
|
351
|
+
"""A probe-open event: carries the fresh, still-in-flight probe."""
|
|
352
|
+
|
|
353
|
+
probe: Probe
|
|
354
|
+
at: int
|
|
355
|
+
phase: ClassVar[Literal["open"]] = "open"
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
@dataclass(frozen=True, slots=True)
|
|
359
|
+
class UpdateSignal:
|
|
360
|
+
"""A probe-update event: attributes amended (probe reconstructed, still open)."""
|
|
361
|
+
|
|
362
|
+
probe: Probe
|
|
363
|
+
at: int
|
|
364
|
+
phase: ClassVar[Literal["update"]] = "update"
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
@dataclass(frozen=True, slots=True)
|
|
368
|
+
class CloseSignal:
|
|
369
|
+
"""A probe-close event: carries the terminal probe a sink persists."""
|
|
370
|
+
|
|
371
|
+
probe: Probe
|
|
372
|
+
at: int
|
|
373
|
+
phase: ClassVar[Literal["close"]] = "close"
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
Signal: TypeAlias = OpenSignal | UpdateSignal | CloseSignal
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def is_close_signal(signal: Signal) -> bool:
|
|
380
|
+
"""Narrow a :data:`Signal` to a terminal :class:`CloseSignal`."""
|
|
381
|
+
return isinstance(signal, CloseSignal)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
# --------------------------------------------------------------------------
|
|
385
|
+
# Sampling gates (delegating to the framework SampleGate / RatioStrategy)
|
|
386
|
+
# --------------------------------------------------------------------------
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
class InsightGate(Protocol):
|
|
390
|
+
"""The app's admission contract: a sticky, trail-id-keyed verdict."""
|
|
391
|
+
|
|
392
|
+
def verdict(self, trail_id: TrailId) -> bool:
|
|
393
|
+
"""``True`` admits the trail (live handles); ``False`` collapses it."""
|
|
394
|
+
...
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
class _FixedGate:
|
|
398
|
+
"""A constant-verdict gate wrapping a framework ``SampleGate``."""
|
|
399
|
+
|
|
400
|
+
__slots__ = ("_gate",)
|
|
401
|
+
|
|
402
|
+
def __init__(self, strategy: Literal["always", "never"]) -> None:
|
|
403
|
+
self._gate = SampleGate(strategy)
|
|
404
|
+
|
|
405
|
+
def verdict(self, trail_id: TrailId) -> bool:
|
|
406
|
+
return self._gate.decide(trail_id)
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
#: Admit every trail (the default when sampling is off).
|
|
410
|
+
always_gate: InsightGate = _FixedGate("always")
|
|
411
|
+
#: Reject every trail (a disabled recorder).
|
|
412
|
+
never_gate: InsightGate = _FixedGate("never")
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
def _clamp_fraction(fraction: float) -> float:
|
|
416
|
+
"""Clamp into ``[0, 1]``; NaN fails closed to 0 (mirrors the TS clamp)."""
|
|
417
|
+
if math.isnan(fraction):
|
|
418
|
+
return 0.0
|
|
419
|
+
if fraction <= 0:
|
|
420
|
+
return 0.0
|
|
421
|
+
if fraction >= 1:
|
|
422
|
+
return 1.0
|
|
423
|
+
return fraction
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
class RatioGate:
|
|
427
|
+
"""Admit a deterministic fraction of trails, keyed on the trail id.
|
|
428
|
+
|
|
429
|
+
Pure delegation to the framework ``SampleGate(RatioStrategy)`` — its
|
|
430
|
+
FNV-1a unit hash was verified bit-identical to the TS ``hash32`` (same
|
|
431
|
+
32-bit digests for the test fixture ids), so the verdict is sticky per id
|
|
432
|
+
and agrees across the TS and Python builds.
|
|
433
|
+
"""
|
|
434
|
+
|
|
435
|
+
__slots__ = ("fraction", "_gate")
|
|
436
|
+
|
|
437
|
+
def __init__(self, fraction: float) -> None:
|
|
438
|
+
# The exposed, clamped fraction (the framework clamps internally too).
|
|
439
|
+
self.fraction = _clamp_fraction(fraction)
|
|
440
|
+
self._gate = SampleGate(RatioStrategy(self.fraction))
|
|
441
|
+
|
|
442
|
+
def verdict(self, trail_id: TrailId) -> bool:
|
|
443
|
+
return self._gate.decide(trail_id)
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def ratio_gate(fraction: float) -> RatioGate:
|
|
447
|
+
"""Build a :class:`RatioGate` admitting roughly ``fraction`` of all trails."""
|
|
448
|
+
return RatioGate(fraction)
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
# --------------------------------------------------------------------------
|
|
452
|
+
# Secret redaction (framework SecretScrubber + the app's rules/token/cap)
|
|
453
|
+
# --------------------------------------------------------------------------
|
|
454
|
+
|
|
455
|
+
#: The app's redaction sentinel — pinned in the wrapper; the framework default
|
|
456
|
+
#: is ``"‹redacted›"`` and is overridden via the scrubber's ``token=`` option.
|
|
457
|
+
REDACTED_TOKEN = "[insight:scrubbed]"
|
|
458
|
+
|
|
459
|
+
#: Hard cap on retained string length (the framework scrubber has no cap, so
|
|
460
|
+
#: the wrapper applies it after scrubbing).
|
|
461
|
+
DEFAULT_MAX_STRING_LENGTH = 4096
|
|
462
|
+
|
|
463
|
+
#: Suffix appended to a string truncated for length.
|
|
464
|
+
TRUNCATION_SUFFIX = "...[insight:truncated]"
|
|
465
|
+
|
|
466
|
+
# The app's own rule set, expressed as framework SecretPatterns: one
|
|
467
|
+
# case-insensitive key rule over the secret-bearing key fragments, plus one
|
|
468
|
+
# value rule per credential shape (ported from the TS DEFAULT_REDACTION_RULES).
|
|
469
|
+
APP_SECRET_PATTERNS: tuple[SecretPattern, ...] = (
|
|
470
|
+
SecretPattern(
|
|
471
|
+
label="secret-key",
|
|
472
|
+
key=re.compile(
|
|
473
|
+
r"(?:apikey|api_key|secret|password|passwd|authorization|auth_token"
|
|
474
|
+
r"|access_token|refresh_token|session_token|private_key|credential"
|
|
475
|
+
r"|cookie|set-cookie|bearer)",
|
|
476
|
+
re.IGNORECASE,
|
|
477
|
+
),
|
|
478
|
+
),
|
|
479
|
+
# Authorization scheme prefixes followed by an opaque credential run.
|
|
480
|
+
SecretPattern(
|
|
481
|
+
label="secret-value-scheme",
|
|
482
|
+
value=re.compile(r"\b(?:Bearer|Basic|Token)\s+[A-Za-z0-9._~+/=-]{8,}"),
|
|
483
|
+
),
|
|
484
|
+
# Provider-style keys: sk-..., pk-..., rk-..., and the live/test variants.
|
|
485
|
+
SecretPattern(
|
|
486
|
+
label="secret-value-provider-key",
|
|
487
|
+
value=re.compile(r"\b[a-z]{1,5}-(?:live|test|proj)?[-_]?[A-Za-z0-9]{16,}\b"),
|
|
488
|
+
),
|
|
489
|
+
# GitHub fine-grained / classic tokens.
|
|
490
|
+
SecretPattern(
|
|
491
|
+
label="secret-value-github",
|
|
492
|
+
value=re.compile(r"\b(?:gh[opsu]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,})\b"),
|
|
493
|
+
),
|
|
494
|
+
# AWS access key ids.
|
|
495
|
+
SecretPattern(label="secret-value-aws", value=re.compile(r"\bAKIA[0-9A-Z]{12,}\b")),
|
|
496
|
+
# JSON Web Tokens (three dot-separated base64url segments).
|
|
497
|
+
SecretPattern(
|
|
498
|
+
label="secret-value-jwt",
|
|
499
|
+
value=re.compile(
|
|
500
|
+
r"\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b"
|
|
501
|
+
),
|
|
502
|
+
),
|
|
503
|
+
# PEM private-key blocks (header through footer, across newlines).
|
|
504
|
+
SecretPattern(
|
|
505
|
+
label="secret-value-pem",
|
|
506
|
+
value=re.compile(
|
|
507
|
+
r"-----BEGIN[ A-Z]*PRIVATE KEY-----[\s\S]*?-----END[ A-Z]*PRIVATE KEY-----"
|
|
508
|
+
),
|
|
509
|
+
),
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
class SecretRedactor:
|
|
513
|
+
"""The app's attribute processor, re-ported from the TS ``createRedactor``.
|
|
514
|
+
|
|
515
|
+
Two rule kinds, taken from the framework :class:`SecretPattern` objects the
|
|
516
|
+
app configures:
|
|
517
|
+
|
|
518
|
+
- a **key** rule tokenizes the *whole subtree* under a secret-named key (so
|
|
519
|
+
``auth: {token: …}`` collapses to the token — matching the framework
|
|
520
|
+
scrubber and the TS ``scrubNode`` short-circuit);
|
|
521
|
+
- a **value** rule replaces only the *matched run(s)* inside a string with
|
|
522
|
+
the token via :func:`re.sub`, leaving the surrounding prose intact (the
|
|
523
|
+
TS ``applyValueRules`` substitution semantics — *not* the framework
|
|
524
|
+
scrubber's whole-value replacement).
|
|
525
|
+
|
|
526
|
+
A key listed in ``omit_keys`` is dropped from the output container entirely
|
|
527
|
+
rather than tokenized (the TS ``RedactionOptions.omitKeys``). The walk is
|
|
528
|
+
depth-first, tracking the enclosing key; every output structure is freshly
|
|
529
|
+
allocated and the input is never mutated.
|
|
530
|
+
|
|
531
|
+
The wrapper-side length cap (the framework scrubber has none) is folded into
|
|
532
|
+
the same walk, applied to scrubbed string leaves.
|
|
533
|
+
|
|
534
|
+
.. note::
|
|
535
|
+
The matched-run value substitution is re-ported here rather than
|
|
536
|
+
delegated to the framework ``SecretScrubber`` (whose ``_sanitize_leaf``
|
|
537
|
+
replaces a matching string value *wholesale*). The app's
|
|
538
|
+
:data:`APP_SECRET_PATTERNS` are reused unchanged, so the rule set — and
|
|
539
|
+
its FNV-parity-verified token — stays the framework's; only the
|
|
540
|
+
substitution policy is the wrapper's.
|
|
541
|
+
"""
|
|
542
|
+
|
|
543
|
+
__slots__ = ("_key_rules", "_value_rules", "_max_len", "_omit")
|
|
544
|
+
|
|
545
|
+
def __init__(
|
|
546
|
+
self,
|
|
547
|
+
patterns: Iterable[SecretPattern] | None = None,
|
|
548
|
+
*,
|
|
549
|
+
max_string_length: int = DEFAULT_MAX_STRING_LENGTH,
|
|
550
|
+
omit_keys: Iterable[str] = (),
|
|
551
|
+
) -> None:
|
|
552
|
+
resolved = (
|
|
553
|
+
tuple(patterns) if patterns is not None else APP_SECRET_PATTERNS
|
|
554
|
+
)
|
|
555
|
+
# Pre-split the rule set so the hot walk never re-checks which side a
|
|
556
|
+
# rule carries (mirrors the framework scrubber's pre-split views).
|
|
557
|
+
self._key_rules: tuple[re.Pattern[str], ...] = tuple(
|
|
558
|
+
p.key for p in resolved if p.key is not None
|
|
559
|
+
)
|
|
560
|
+
self._value_rules: tuple[re.Pattern[str], ...] = tuple(
|
|
561
|
+
p.value for p in resolved if p.value is not None
|
|
562
|
+
)
|
|
563
|
+
self._max_len = max_string_length
|
|
564
|
+
# Lower-cased for case-insensitive membership, as in the TS omit set.
|
|
565
|
+
self._omit: frozenset[str] = frozenset(k.lower() for k in omit_keys)
|
|
566
|
+
|
|
567
|
+
def _is_omitted(self, key: str) -> bool:
|
|
568
|
+
"""Whether ``key`` should be dropped from the output container wholesale."""
|
|
569
|
+
return key.lower() in self._omit
|
|
570
|
+
|
|
571
|
+
def _is_secret_key(self, key: str) -> bool:
|
|
572
|
+
"""Whether ``key`` trips any key rule (case-insensitive, unanchored)."""
|
|
573
|
+
for rule in self._key_rules:
|
|
574
|
+
if rule.search(key) is not None:
|
|
575
|
+
return True
|
|
576
|
+
return False
|
|
577
|
+
|
|
578
|
+
def _apply_value_rules(self, text: str) -> str:
|
|
579
|
+
"""Replace every value-rule run in ``text`` with the token.
|
|
580
|
+
|
|
581
|
+
Per-rule :func:`re.sub` over the matched span only — surrounding,
|
|
582
|
+
non-secret text is preserved (the TS ``applyValueRules`` policy).
|
|
583
|
+
"""
|
|
584
|
+
out = text
|
|
585
|
+
for rule in self._value_rules:
|
|
586
|
+
out = rule.sub(REDACTED_TOKEN, out)
|
|
587
|
+
return out
|
|
588
|
+
|
|
589
|
+
def _cap(self, text: str) -> str:
|
|
590
|
+
"""Cap a string at the max length, appending the truncation marker."""
|
|
591
|
+
if len(text) <= self._max_len:
|
|
592
|
+
return text
|
|
593
|
+
return text[: self._max_len] + TRUNCATION_SUFFIX
|
|
594
|
+
|
|
595
|
+
def _scrub_node(self, value: object, key: str | None) -> object:
|
|
596
|
+
"""Scrub one value depth-first, tracking the enclosing key.
|
|
597
|
+
|
|
598
|
+
A non-``None`` ``key`` that names a secret tokenizes the whole subtree.
|
|
599
|
+
Strings additionally run the value rules and the length cap. Mappings
|
|
600
|
+
and sequences recurse (dropping ``omit_keys``); everything else passes
|
|
601
|
+
through. Output is always freshly allocated.
|
|
602
|
+
"""
|
|
603
|
+
if key is not None and self._is_secret_key(key):
|
|
604
|
+
return REDACTED_TOKEN
|
|
605
|
+
if isinstance(value, str):
|
|
606
|
+
return self._cap(self._apply_value_rules(value))
|
|
607
|
+
if isinstance(value, Mapping):
|
|
608
|
+
out: dict[str, object] = {}
|
|
609
|
+
for k, v in value.items():
|
|
610
|
+
ks = str(k)
|
|
611
|
+
if self._is_omitted(ks):
|
|
612
|
+
continue
|
|
613
|
+
out[ks] = self._scrub_node(v, ks)
|
|
614
|
+
return out
|
|
615
|
+
if isinstance(value, (list, tuple)):
|
|
616
|
+
return [self._scrub_node(item, None) for item in value]
|
|
617
|
+
return value
|
|
618
|
+
|
|
619
|
+
def scrub_value(self, value: object) -> object:
|
|
620
|
+
"""Scrub a single value (string / mapping / sequence), recursively."""
|
|
621
|
+
return self._scrub_node(value, None)
|
|
622
|
+
|
|
623
|
+
def scrub_attributes(self, attributes: ProbeAttributes) -> ProbeAttributes:
|
|
624
|
+
"""Scrub an attribute bag, returning a fresh read-only bag."""
|
|
625
|
+
out: dict[str, object] = {}
|
|
626
|
+
for k, v in attributes.items():
|
|
627
|
+
ks = str(k)
|
|
628
|
+
if self._is_omitted(ks):
|
|
629
|
+
continue
|
|
630
|
+
out[ks] = self._scrub_node(v, ks)
|
|
631
|
+
return MappingProxyType(out)
|
|
632
|
+
|
|
633
|
+
def scrub_fault(self, fault: ProbeFault) -> ProbeFault:
|
|
634
|
+
"""Scrub a fault's free-text fields with the value rules and cap."""
|
|
635
|
+
message = self.scrub_value(fault.message)
|
|
636
|
+
stack = self.scrub_value(fault.stack) if fault.stack is not None else None
|
|
637
|
+
return ProbeFault(
|
|
638
|
+
message=message if isinstance(message, str) else fault.message,
|
|
639
|
+
name=fault.name,
|
|
640
|
+
stack=stack if isinstance(stack, str) or stack is None else fault.stack,
|
|
641
|
+
)
|
|
642
|
+
|
|
643
|
+
def scrub_probe(self, probe: Probe) -> Probe:
|
|
644
|
+
"""Scrub a whole probe (attributes + fault), returning a new frozen probe."""
|
|
645
|
+
return Probe(
|
|
646
|
+
id=probe.id,
|
|
647
|
+
trail_id=probe.trail_id,
|
|
648
|
+
parent_id=probe.parent_id,
|
|
649
|
+
kind=probe.kind,
|
|
650
|
+
name=probe.name,
|
|
651
|
+
started_at=probe.started_at,
|
|
652
|
+
ended_at=probe.ended_at,
|
|
653
|
+
status=probe.status,
|
|
654
|
+
attributes=self.scrub_attributes(probe.attributes),
|
|
655
|
+
fault=self.scrub_fault(probe.fault) if probe.fault is not None else None,
|
|
656
|
+
)
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
class _PassthruRedactor:
|
|
660
|
+
"""The identity processor used when redaction is disabled."""
|
|
661
|
+
|
|
662
|
+
__slots__ = ()
|
|
663
|
+
|
|
664
|
+
def scrub_value(self, value: object) -> object:
|
|
665
|
+
return value
|
|
666
|
+
|
|
667
|
+
def scrub_attributes(self, attributes: ProbeAttributes) -> ProbeAttributes:
|
|
668
|
+
return attributes
|
|
669
|
+
|
|
670
|
+
def scrub_fault(self, fault: ProbeFault) -> ProbeFault:
|
|
671
|
+
return fault
|
|
672
|
+
|
|
673
|
+
def scrub_probe(self, probe: Probe) -> Probe:
|
|
674
|
+
return probe
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
#: A redactor that performs no redaction (the recorder's default, as in TS).
|
|
678
|
+
PASSTHRU_REDACTOR = _PassthruRedactor()
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
def create_redactor(
|
|
682
|
+
patterns: Iterable[SecretPattern] | None = None,
|
|
683
|
+
*,
|
|
684
|
+
max_string_length: int = DEFAULT_MAX_STRING_LENGTH,
|
|
685
|
+
omit_keys: Iterable[str] = (),
|
|
686
|
+
) -> SecretRedactor:
|
|
687
|
+
"""Build a :class:`SecretRedactor` from a rule set and options.
|
|
688
|
+
|
|
689
|
+
``omit_keys`` (the TS ``RedactionOptions.omitKeys``) names attribute keys
|
|
690
|
+
dropped from the scrubbed output container entirely, rather than tokenized;
|
|
691
|
+
it defaults to empty, so the default redactor's behaviour is unchanged.
|
|
692
|
+
"""
|
|
693
|
+
return SecretRedactor(
|
|
694
|
+
patterns, max_string_length=max_string_length, omit_keys=omit_keys
|
|
695
|
+
)
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
#: A ready-built redactor using the app's default rules and caps.
|
|
699
|
+
DEFAULT_REDACTOR = create_redactor()
|
|
700
|
+
|
|
701
|
+
# --------------------------------------------------------------------------
|
|
702
|
+
# Probe handles
|
|
703
|
+
# --------------------------------------------------------------------------
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
class ProbeHandle(Protocol):
|
|
707
|
+
"""The behavioural face of a single in-flight probe.
|
|
708
|
+
|
|
709
|
+
``note`` / ``child`` / ``fail`` are no-ops after ``close``; ``close`` is
|
|
710
|
+
idempotent (first call wins) — the framework's forgiving lifecycle.
|
|
711
|
+
"""
|
|
712
|
+
|
|
713
|
+
@property
|
|
714
|
+
def trail_id(self) -> TrailId: ...
|
|
715
|
+
|
|
716
|
+
@property
|
|
717
|
+
def id(self) -> ProbeId: ...
|
|
718
|
+
|
|
719
|
+
@property
|
|
720
|
+
def sampled(self) -> bool: ...
|
|
721
|
+
|
|
722
|
+
def note(self, attributes: ProbeAttributes) -> None: ...
|
|
723
|
+
|
|
724
|
+
def child(
|
|
725
|
+
self,
|
|
726
|
+
kind: ProbeKind,
|
|
727
|
+
name: str,
|
|
728
|
+
attributes: ProbeAttributes | None = None,
|
|
729
|
+
) -> "ProbeHandle": ...
|
|
730
|
+
|
|
731
|
+
def fail(self, error: object) -> None: ...
|
|
732
|
+
|
|
733
|
+
def close(self, outcome: ProbeOutcome | None = None) -> None: ...
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
class _NoopProbeHandle:
|
|
737
|
+
"""The single shared sampled-out handle (the framework NOOP idea, with the
|
|
738
|
+
app's ``sampled``/``trail_id`` spelling and child-attributes signature)."""
|
|
739
|
+
|
|
740
|
+
__slots__ = ()
|
|
741
|
+
|
|
742
|
+
@property
|
|
743
|
+
def trail_id(self) -> TrailId:
|
|
744
|
+
return ""
|
|
745
|
+
|
|
746
|
+
@property
|
|
747
|
+
def id(self) -> ProbeId:
|
|
748
|
+
return ""
|
|
749
|
+
|
|
750
|
+
@property
|
|
751
|
+
def sampled(self) -> bool:
|
|
752
|
+
return False
|
|
753
|
+
|
|
754
|
+
def note(self, attributes: ProbeAttributes) -> None:
|
|
755
|
+
return None
|
|
756
|
+
|
|
757
|
+
def child(
|
|
758
|
+
self,
|
|
759
|
+
kind: ProbeKind,
|
|
760
|
+
name: str,
|
|
761
|
+
attributes: ProbeAttributes | None = None,
|
|
762
|
+
) -> ProbeHandle:
|
|
763
|
+
# Returns *itself* so an entire sampled-out subtree costs nothing.
|
|
764
|
+
return NOOP_HANDLE
|
|
765
|
+
|
|
766
|
+
def fail(self, error: object) -> None:
|
|
767
|
+
return None
|
|
768
|
+
|
|
769
|
+
def close(self, outcome: ProbeOutcome | None = None) -> None:
|
|
770
|
+
return None
|
|
771
|
+
|
|
772
|
+
|
|
773
|
+
#: The frozen sampled-out handle; ``child`` returns itself.
|
|
774
|
+
NOOP_HANDLE: ProbeHandle = _NoopProbeHandle()
|
|
775
|
+
|
|
776
|
+
|
|
777
|
+
class _LiveProbeHandle:
|
|
778
|
+
"""A sampled-in handle: drives the framework segment value functions with
|
|
779
|
+
the recorder's injected clock and emits framework signals."""
|
|
780
|
+
|
|
781
|
+
__slots__ = ("_recorder", "_segment", "_fault", "_done")
|
|
782
|
+
|
|
783
|
+
def __init__(self, recorder: "InsightRecorder", segment: Segment) -> None:
|
|
784
|
+
self._recorder = recorder
|
|
785
|
+
self._segment = segment
|
|
786
|
+
self._fault: ProbeFault | None = None
|
|
787
|
+
self._done = False
|
|
788
|
+
|
|
789
|
+
@property
|
|
790
|
+
def trail_id(self) -> TrailId:
|
|
791
|
+
return self._segment.trace_id
|
|
792
|
+
|
|
793
|
+
@property
|
|
794
|
+
def id(self) -> ProbeId:
|
|
795
|
+
return self._segment.id
|
|
796
|
+
|
|
797
|
+
@property
|
|
798
|
+
def sampled(self) -> bool:
|
|
799
|
+
return True
|
|
800
|
+
|
|
801
|
+
def note(self, attributes: ProbeAttributes) -> None:
|
|
802
|
+
if self._done:
|
|
803
|
+
return
|
|
804
|
+
clean = self._recorder._redactor.scrub_attributes(attributes)
|
|
805
|
+
self._segment = with_attributes(self._segment, clean)
|
|
806
|
+
self._recorder._emit(FwUpdateSignal(id=self._segment.id, attributes=clean))
|
|
807
|
+
|
|
808
|
+
def child(
|
|
809
|
+
self,
|
|
810
|
+
kind: ProbeKind,
|
|
811
|
+
name: str,
|
|
812
|
+
attributes: ProbeAttributes | None = None,
|
|
813
|
+
) -> ProbeHandle:
|
|
814
|
+
if self._done:
|
|
815
|
+
return NOOP_HANDLE
|
|
816
|
+
return self._recorder._open_probe(
|
|
817
|
+
self._segment.trace_id, self._segment.id, kind, name, attributes
|
|
818
|
+
)
|
|
819
|
+
|
|
820
|
+
def fail(self, error: object) -> None:
|
|
821
|
+
# Emit an in-flight update reflecting the failure (mirroring the TS
|
|
822
|
+
# ``recorder.fail`` which routes a ``status:"error"`` update before
|
|
823
|
+
# close). The framework ``UpdateSignal`` carries only ``(id, attributes)``
|
|
824
|
+
# — no status/fault — so the failure rides the reserved fault attributes
|
|
825
|
+
# (message + name/stack), which the ``signals()`` adapter lifts back into
|
|
826
|
+
# an error-status probe. The full fault still also travels in the close
|
|
827
|
+
# record; this update makes it observable the moment ``fail`` is called.
|
|
828
|
+
if self._done:
|
|
829
|
+
return
|
|
830
|
+
self._fault = self._recorder._redactor.scrub_fault(fault_of(error))
|
|
831
|
+
fault_attrs: dict[str, object] = {
|
|
832
|
+
FAULT_MESSAGE_KEY: self._fault.message,
|
|
833
|
+
**_fault_attributes(self._fault),
|
|
834
|
+
}
|
|
835
|
+
self._segment = with_attributes(self._segment, fault_attrs)
|
|
836
|
+
self._recorder._emit(
|
|
837
|
+
FwUpdateSignal(id=self._segment.id, attributes=MappingProxyType(fault_attrs))
|
|
838
|
+
)
|
|
839
|
+
|
|
840
|
+
def close(self, outcome: ProbeOutcome | None = None) -> None:
|
|
841
|
+
if self._done:
|
|
842
|
+
return
|
|
843
|
+
self._done = True
|
|
844
|
+
status: ProbeOutcome = outcome if outcome is not None else (
|
|
845
|
+
"error" if self._fault is not None else "ok"
|
|
846
|
+
)
|
|
847
|
+
error = (
|
|
848
|
+
SegmentError(message=self._fault.message)
|
|
849
|
+
if self._fault is not None and status == "error"
|
|
850
|
+
else None
|
|
851
|
+
)
|
|
852
|
+
# Carry the fault's name + stack through the framework's message-only
|
|
853
|
+
# ``SegmentError`` by stashing them in the reserved attribute keys; the
|
|
854
|
+
# file sink serializes attributes verbatim, so they survive NDJSON and
|
|
855
|
+
# replay (the close-signal adapter / ``record_to_probe`` lift them back).
|
|
856
|
+
if error is not None and self._fault is not None:
|
|
857
|
+
extra = _fault_attributes(self._fault)
|
|
858
|
+
if extra:
|
|
859
|
+
self._segment = with_attributes(self._segment, extra)
|
|
860
|
+
record = close_segment(
|
|
861
|
+
self._segment,
|
|
862
|
+
status,
|
|
863
|
+
ended_at=self._recorder._now(),
|
|
864
|
+
error=error,
|
|
865
|
+
)
|
|
866
|
+
self._segment = record
|
|
867
|
+
self._recorder._emit(FwCloseSignal(record=record))
|
|
868
|
+
|
|
869
|
+
|
|
870
|
+
# --------------------------------------------------------------------------
|
|
871
|
+
# Recorder
|
|
872
|
+
# --------------------------------------------------------------------------
|
|
873
|
+
|
|
874
|
+
|
|
875
|
+
def _to_gate(gate: object) -> InsightGate:
|
|
876
|
+
"""Coerce a gate spec (wrapper gate / framework SampleGate / strategy)."""
|
|
877
|
+
if hasattr(gate, "verdict"):
|
|
878
|
+
return gate # type: ignore[return-value]
|
|
879
|
+
if isinstance(gate, SampleGate):
|
|
880
|
+
framework = gate
|
|
881
|
+
|
|
882
|
+
class _Adapted:
|
|
883
|
+
__slots__ = ()
|
|
884
|
+
|
|
885
|
+
def verdict(self, trail_id: TrailId) -> bool:
|
|
886
|
+
return framework.decide(trail_id)
|
|
887
|
+
|
|
888
|
+
return _Adapted()
|
|
889
|
+
if isinstance(gate, RatioStrategy):
|
|
890
|
+
return ratio_gate(gate.ratio)
|
|
891
|
+
if gate == "always":
|
|
892
|
+
return always_gate
|
|
893
|
+
if gate == "never":
|
|
894
|
+
return never_gate
|
|
895
|
+
raise TypeError(f"insight: unsupported sample gate {gate!r}")
|
|
896
|
+
|
|
897
|
+
|
|
898
|
+
@dataclass(frozen=True, slots=True)
|
|
899
|
+
class TrailRecorder:
|
|
900
|
+
"""A recorder view bound to one trail (``Recorder.scope`` in the TS surface)."""
|
|
901
|
+
|
|
902
|
+
trail_id: TrailId
|
|
903
|
+
sampled: bool
|
|
904
|
+
_recorder: "InsightRecorder"
|
|
905
|
+
|
|
906
|
+
def open(
|
|
907
|
+
self,
|
|
908
|
+
kind: ProbeKind,
|
|
909
|
+
name: str,
|
|
910
|
+
attributes: ProbeAttributes | None = None,
|
|
911
|
+
) -> ProbeHandle:
|
|
912
|
+
"""Open a probe at the trail root level; returns its handle."""
|
|
913
|
+
if not self.sampled:
|
|
914
|
+
return NOOP_HANDLE
|
|
915
|
+
return self._recorder._open_probe(self.trail_id, None, kind, name, attributes)
|
|
916
|
+
|
|
917
|
+
def child(
|
|
918
|
+
self,
|
|
919
|
+
parent_id: ProbeId,
|
|
920
|
+
kind: ProbeKind,
|
|
921
|
+
name: str,
|
|
922
|
+
attributes: ProbeAttributes | None = None,
|
|
923
|
+
) -> ProbeHandle:
|
|
924
|
+
"""Open a probe under the given parent probe id; returns its handle."""
|
|
925
|
+
if not self.sampled:
|
|
926
|
+
return NOOP_HANDLE
|
|
927
|
+
return self._recorder._open_probe(self.trail_id, parent_id, kind, name, attributes)
|
|
928
|
+
|
|
929
|
+
|
|
930
|
+
class InsightRecorder:
|
|
931
|
+
"""The single recorder facade the agent talks to — the TS ``createRecorder``
|
|
932
|
+
surface over the framework transport.
|
|
933
|
+
|
|
934
|
+
Owns a framework :class:`SignalChannel` (exposed as :attr:`channel`, the
|
|
935
|
+
stream framework sinks drain), the admission gate, the redactor, and the
|
|
936
|
+
injectable clock. ``sinks`` passed at construction each get their own
|
|
937
|
+
fan-out channel (the framework channel is single-consumer) and a drain
|
|
938
|
+
task — which requires a running event loop.
|
|
939
|
+
"""
|
|
940
|
+
|
|
941
|
+
__slots__ = ("service", "enabled", "channel", "_gate", "_redactor", "_now",
|
|
942
|
+
"_sink_channels", "_drains")
|
|
943
|
+
|
|
944
|
+
def __init__(
|
|
945
|
+
self,
|
|
946
|
+
*,
|
|
947
|
+
service: str = "insight",
|
|
948
|
+
enabled: bool = True,
|
|
949
|
+
gate: object | None = None,
|
|
950
|
+
redactor: object | None = None,
|
|
951
|
+
sinks: Iterable[Sink] = (),
|
|
952
|
+
now: Callable[[], int] | None = None,
|
|
953
|
+
channel: SignalChannel | None = None,
|
|
954
|
+
) -> None:
|
|
955
|
+
self.service = service
|
|
956
|
+
self.enabled = enabled
|
|
957
|
+
self._gate: InsightGate = (
|
|
958
|
+
_to_gate(gate) if gate is not None else always_gate
|
|
959
|
+
) if enabled else never_gate
|
|
960
|
+
self._redactor = redactor if redactor is not None else PASSTHRU_REDACTOR
|
|
961
|
+
self._now: Callable[[], int] = now if now is not None else _now_ms
|
|
962
|
+
self.channel: SignalChannel = channel if channel is not None else SignalChannel()
|
|
963
|
+
self._sink_channels: list[SignalChannel] = []
|
|
964
|
+
self._drains: list[asyncio.Task[None]] = []
|
|
965
|
+
for sink in sinks:
|
|
966
|
+
sink_channel = SignalChannel()
|
|
967
|
+
self._sink_channels.append(sink_channel)
|
|
968
|
+
self._drains.append(
|
|
969
|
+
asyncio.get_running_loop().create_task(sink.drain(sink_channel))
|
|
970
|
+
)
|
|
971
|
+
|
|
972
|
+
# -- emission ----------------------------------------------------------
|
|
973
|
+
|
|
974
|
+
def _emit(self, signal: TraceSignal) -> None:
|
|
975
|
+
"""Fan one framework signal out to the main channel and every sink."""
|
|
976
|
+
self.channel.emit(signal)
|
|
977
|
+
for sink_channel in self._sink_channels:
|
|
978
|
+
sink_channel.emit(signal)
|
|
979
|
+
|
|
980
|
+
def _open_probe(
|
|
981
|
+
self,
|
|
982
|
+
trail_id: TrailId,
|
|
983
|
+
parent_id: ProbeId | None,
|
|
984
|
+
kind: ProbeKind,
|
|
985
|
+
name: str,
|
|
986
|
+
attributes: ProbeAttributes | None,
|
|
987
|
+
) -> ProbeHandle:
|
|
988
|
+
if kind not in KIND_TO_SEGMENT:
|
|
989
|
+
raise ValueError(f"insight: unknown probe kind {kind!r}")
|
|
990
|
+
clean = self._redactor.scrub_attributes(attributes if attributes is not None else {})
|
|
991
|
+
segment = open_segment(
|
|
992
|
+
OpenSegmentInput(
|
|
993
|
+
kind=KIND_TO_SEGMENT[kind],
|
|
994
|
+
name=name,
|
|
995
|
+
trace_id=trail_id,
|
|
996
|
+
parent_id=parent_id,
|
|
997
|
+
id=mint_probe_id(),
|
|
998
|
+
attributes=clean,
|
|
999
|
+
started_at=self._now(),
|
|
1000
|
+
)
|
|
1001
|
+
)
|
|
1002
|
+
handle = _LiveProbeHandle(self, segment)
|
|
1003
|
+
self._emit(FwOpenSignal(segment=segment))
|
|
1004
|
+
return handle
|
|
1005
|
+
|
|
1006
|
+
# -- the app surface ----------------------------------------------------
|
|
1007
|
+
|
|
1008
|
+
def open_trail(
|
|
1009
|
+
self,
|
|
1010
|
+
name: str,
|
|
1011
|
+
*,
|
|
1012
|
+
kind: ProbeKind = "run",
|
|
1013
|
+
trail_id: TrailId | None = None,
|
|
1014
|
+
attributes: ProbeAttributes | None = None,
|
|
1015
|
+
) -> ProbeHandle:
|
|
1016
|
+
"""Open a brand-new trail; returns its root handle (or NOOP when gated out)."""
|
|
1017
|
+
resolved = trail_id if trail_id is not None else mint_trail_id()
|
|
1018
|
+
if not self.enabled or not self._gate.verdict(resolved):
|
|
1019
|
+
return NOOP_HANDLE
|
|
1020
|
+
return self._open_probe(resolved, None, kind, name, attributes)
|
|
1021
|
+
|
|
1022
|
+
def scope(self, trail_id: TrailId) -> TrailRecorder:
|
|
1023
|
+
"""Bind a :class:`TrailRecorder` view to an existing trail id."""
|
|
1024
|
+
sampled = self.enabled and self._gate.verdict(trail_id)
|
|
1025
|
+
return TrailRecorder(trail_id=trail_id, sampled=sampled, _recorder=self)
|
|
1026
|
+
|
|
1027
|
+
async def signals(self) -> AsyncIterator[Signal]:
|
|
1028
|
+
"""Adapt the framework channel into app-vocabulary :data:`Signal` values.
|
|
1029
|
+
|
|
1030
|
+
Single-consumer, like the underlying channel: call once and drain.
|
|
1031
|
+
Update probes are reconstructed statefully from the open they amend
|
|
1032
|
+
(the framework update signal carries only ``(id, attributes)``), and
|
|
1033
|
+
an update's ``at`` is stamped at observation time.
|
|
1034
|
+
"""
|
|
1035
|
+
open_probes: dict[ProbeId, Probe] = {}
|
|
1036
|
+
async for raw in self.channel:
|
|
1037
|
+
if isinstance(raw, FwOpenSignal):
|
|
1038
|
+
probe = probe_from_segment(raw.segment)
|
|
1039
|
+
open_probes[probe.id] = probe
|
|
1040
|
+
yield OpenSignal(probe=probe, at=probe.started_at)
|
|
1041
|
+
elif isinstance(raw, FwUpdateSignal):
|
|
1042
|
+
prior = open_probes.get(raw.id)
|
|
1043
|
+
if prior is None:
|
|
1044
|
+
continue
|
|
1045
|
+
amended = {**prior.attributes, **raw.attributes}
|
|
1046
|
+
# A ``fail()`` update carries the reserved fault attributes; lift
|
|
1047
|
+
# them onto the reconstructed probe as an error status + fault
|
|
1048
|
+
# (the TS in-flight ``status:"error"`` update), then strip the
|
|
1049
|
+
# reserved keys so they never surface as caller attributes.
|
|
1050
|
+
fault = prior.fault
|
|
1051
|
+
status = prior.status
|
|
1052
|
+
fault_message = amended.pop(FAULT_MESSAGE_KEY, None)
|
|
1053
|
+
recovered = _fault_from_record(
|
|
1054
|
+
fault_message if isinstance(fault_message, str) else None,
|
|
1055
|
+
amended,
|
|
1056
|
+
)
|
|
1057
|
+
if recovered is not None:
|
|
1058
|
+
fault = recovered
|
|
1059
|
+
status = "error"
|
|
1060
|
+
amended.pop(FAULT_NAME_KEY, None)
|
|
1061
|
+
amended.pop(FAULT_STACK_KEY, None)
|
|
1062
|
+
merged = Probe(
|
|
1063
|
+
id=prior.id,
|
|
1064
|
+
trail_id=prior.trail_id,
|
|
1065
|
+
parent_id=prior.parent_id,
|
|
1066
|
+
kind=prior.kind,
|
|
1067
|
+
name=prior.name,
|
|
1068
|
+
started_at=prior.started_at,
|
|
1069
|
+
ended_at=prior.ended_at,
|
|
1070
|
+
status=status,
|
|
1071
|
+
attributes=MappingProxyType(amended),
|
|
1072
|
+
fault=fault,
|
|
1073
|
+
)
|
|
1074
|
+
open_probes[raw.id] = merged
|
|
1075
|
+
yield UpdateSignal(probe=merged, at=self._now())
|
|
1076
|
+
elif isinstance(raw, FwCloseSignal):
|
|
1077
|
+
probe = probe_from_segment(raw.record)
|
|
1078
|
+
open_probes.pop(probe.id, None)
|
|
1079
|
+
assert probe.ended_at is not None
|
|
1080
|
+
yield CloseSignal(probe=probe, at=probe.ended_at)
|
|
1081
|
+
|
|
1082
|
+
async def flush(self) -> None:
|
|
1083
|
+
"""Yield a tick so synchronously-enqueued signals settle in sink tasks."""
|
|
1084
|
+
await asyncio.sleep(0)
|
|
1085
|
+
|
|
1086
|
+
async def shutdown(self) -> None:
|
|
1087
|
+
"""Flush, close every channel, and await the sink drain tasks."""
|
|
1088
|
+
await self.flush()
|
|
1089
|
+
self.channel.close()
|
|
1090
|
+
for sink_channel in self._sink_channels:
|
|
1091
|
+
sink_channel.close()
|
|
1092
|
+
if self._drains:
|
|
1093
|
+
await asyncio.gather(*self._drains, return_exceptions=True)
|
|
1094
|
+
|
|
1095
|
+
|
|
1096
|
+
def create_recorder(
|
|
1097
|
+
*,
|
|
1098
|
+
service: str = "insight",
|
|
1099
|
+
enabled: bool = True,
|
|
1100
|
+
gate: object | None = None,
|
|
1101
|
+
redactor: object | None = None,
|
|
1102
|
+
sinks: Iterable[Sink] = (),
|
|
1103
|
+
now: Callable[[], int] | None = None,
|
|
1104
|
+
channel: SignalChannel | None = None,
|
|
1105
|
+
) -> InsightRecorder:
|
|
1106
|
+
"""Create the recorder at the head of the insight pipeline (TS ``createRecorder``)."""
|
|
1107
|
+
return InsightRecorder(
|
|
1108
|
+
service=service,
|
|
1109
|
+
enabled=enabled,
|
|
1110
|
+
gate=gate,
|
|
1111
|
+
redactor=redactor,
|
|
1112
|
+
sinks=sinks,
|
|
1113
|
+
now=now,
|
|
1114
|
+
channel=channel,
|
|
1115
|
+
)
|