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.
Files changed (167) hide show
  1. induscode/__init__.py +56 -0
  2. induscode/addons/__init__.py +176 -0
  3. induscode/addons/contract.py +923 -0
  4. induscode/addons/dispatch/__init__.py +43 -0
  5. induscode/addons/dispatch/event_dispatcher.py +348 -0
  6. induscode/addons/dispatch/tool_interceptor.py +349 -0
  7. induscode/addons/host.py +469 -0
  8. induscode/addons/loader.py +314 -0
  9. induscode/addons/manifest.py +232 -0
  10. induscode/addons/surface.py +199 -0
  11. induscode/boot/__init__.py +108 -0
  12. induscode/boot/auth_vault.py +323 -0
  13. induscode/boot/boot.py +210 -0
  14. induscode/boot/contract.py +223 -0
  15. induscode/boot/invocation.py +117 -0
  16. induscode/boot/runners/__init__.py +42 -0
  17. induscode/boot/runners/link_runner.py +82 -0
  18. induscode/boot/runners/oneshot_runner.py +85 -0
  19. induscode/boot/runners/registry.py +46 -0
  20. induscode/boot/runners/repl_runner.py +340 -0
  21. induscode/boot/runners/session.py +549 -0
  22. induscode/boot/stages.py +198 -0
  23. induscode/boot/upgrade/__init__.py +36 -0
  24. induscode/boot/upgrade/apply.py +125 -0
  25. induscode/boot/upgrade/upgrades.py +136 -0
  26. induscode/briefing/__init__.py +115 -0
  27. induscode/briefing/compose.py +414 -0
  28. induscode/briefing/contract.py +528 -0
  29. induscode/briefing/macros.py +721 -0
  30. induscode/briefing/skills.py +417 -0
  31. induscode/capability_deck/__init__.py +233 -0
  32. induscode/capability_deck/bridge_ledger/__init__.py +66 -0
  33. induscode/capability_deck/bridge_ledger/key.py +181 -0
  34. induscode/capability_deck/bridge_ledger/ledger.py +276 -0
  35. induscode/capability_deck/bridge_ledger/network.py +336 -0
  36. induscode/capability_deck/builtin_bridge.py +358 -0
  37. induscode/capability_deck/cards/__init__.py +116 -0
  38. induscode/capability_deck/cards/bg_process.py +482 -0
  39. induscode/capability_deck/cards/memory.py +226 -0
  40. induscode/capability_deck/cards/saas.py +280 -0
  41. induscode/capability_deck/cards/task.py +256 -0
  42. induscode/capability_deck/cards/todo.py +312 -0
  43. induscode/capability_deck/contract.py +450 -0
  44. induscode/capability_deck/manifest.py +126 -0
  45. induscode/capability_deck/provision.py +217 -0
  46. induscode/channels/__init__.py +146 -0
  47. induscode/channels/contract.py +585 -0
  48. induscode/channels/framer.py +132 -0
  49. induscode/channels/link/__init__.py +50 -0
  50. induscode/channels/link/dialog.py +246 -0
  51. induscode/channels/link/driver.py +308 -0
  52. induscode/channels/link/server.py +217 -0
  53. induscode/channels/oneshot.py +178 -0
  54. induscode/channels/ops.py +140 -0
  55. induscode/channels/session_ops.py +172 -0
  56. induscode/conductor/__init__.py +240 -0
  57. induscode/conductor/catalog.py +309 -0
  58. induscode/conductor/conductor.py +1084 -0
  59. induscode/conductor/contract.py +1035 -0
  60. induscode/conductor/matcher.py +291 -0
  61. induscode/conductor/serialize.py +575 -0
  62. induscode/conductor/signal_hub.py +382 -0
  63. induscode/conductor/skill_parse.py +294 -0
  64. induscode/conductor/transcript_store.py +449 -0
  65. induscode/console/__init__.py +236 -0
  66. induscode/console/app.py +1677 -0
  67. induscode/console/components/__init__.py +62 -0
  68. induscode/console/components/banner.py +499 -0
  69. induscode/console/components/banner_sweep.py +188 -0
  70. induscode/console/components/emblem.py +181 -0
  71. induscode/console/components/status_bar.py +102 -0
  72. induscode/console/contract.py +836 -0
  73. induscode/console/input/__init__.py +107 -0
  74. induscode/console/input/chord.py +197 -0
  75. induscode/console/input/dir_reader.py +113 -0
  76. induscode/console/input/intents.py +258 -0
  77. induscode/console/input/providers.py +469 -0
  78. induscode/console/mount.py +137 -0
  79. induscode/console/overlays/__init__.py +94 -0
  80. induscode/console/overlays/auth.py +503 -0
  81. induscode/console/overlays/pickers.py +526 -0
  82. induscode/console/overlays/router.py +129 -0
  83. induscode/console/overlays/sessions.py +232 -0
  84. induscode/console/reducer.py +145 -0
  85. induscode/console/resume_picker.py +156 -0
  86. induscode/console/slash_commands/__init__.py +78 -0
  87. induscode/console/slash_commands/builtins.py +254 -0
  88. induscode/console/slash_commands/dynamic.py +217 -0
  89. induscode/console/slash_commands/integrations.py +949 -0
  90. induscode/console/slash_commands/transcript.py +404 -0
  91. induscode/console/slash_commands/workbench.py +430 -0
  92. induscode/console/startup.py +434 -0
  93. induscode/console/theme/__init__.py +44 -0
  94. induscode/console/theme/adapter.py +168 -0
  95. induscode/console/theme/palette.py +128 -0
  96. induscode/console/theme/resolve.py +123 -0
  97. induscode/console/theme/tokens.py +185 -0
  98. induscode/console_slash/__init__.py +111 -0
  99. induscode/console_slash/contract.py +185 -0
  100. induscode/console_slash/registry.py +140 -0
  101. induscode/console_slash/resolve.py +194 -0
  102. induscode/console_slash/shared.py +172 -0
  103. induscode/entry.py +108 -0
  104. induscode/insight/__init__.py +153 -0
  105. induscode/insight/collector.py +73 -0
  106. induscode/insight/replay.py +305 -0
  107. induscode/insight/wrapper.py +1115 -0
  108. induscode/kit/__init__.py +82 -0
  109. induscode/kit/clipboard_image.py +215 -0
  110. induscode/kit/external_editor.py +120 -0
  111. induscode/kit/image.py +188 -0
  112. induscode/kit/shell.py +89 -0
  113. induscode/kit/tool_fetch.py +288 -0
  114. induscode/launch/__init__.py +224 -0
  115. induscode/launch/catalog.py +310 -0
  116. induscode/launch/contract.py +569 -0
  117. induscode/launch/credentials.py +852 -0
  118. induscode/launch/invocation/__init__.py +39 -0
  119. induscode/launch/invocation/attachments.py +281 -0
  120. induscode/launch/invocation/flags.py +210 -0
  121. induscode/launch/invocation/read.py +369 -0
  122. induscode/launch/invocation/usage.py +110 -0
  123. induscode/launch/oauth.py +808 -0
  124. induscode/launch/packages.py +299 -0
  125. induscode/launch/pickers.py +291 -0
  126. induscode/py.typed +0 -0
  127. induscode/runtime_bridge/__init__.py +166 -0
  128. induscode/runtime_bridge/bridges/__init__.py +66 -0
  129. induscode/runtime_bridge/bridges/_drive.py +268 -0
  130. induscode/runtime_bridge/bridges/builtins.py +177 -0
  131. induscode/runtime_bridge/bridges/claude_cli.py +198 -0
  132. induscode/runtime_bridge/bridges/codex_cli.py +203 -0
  133. induscode/runtime_bridge/bridges/indusagi_cli.py +217 -0
  134. induscode/runtime_bridge/broker.py +397 -0
  135. induscode/runtime_bridge/contract.py +734 -0
  136. induscode/runtime_bridge/sink.py +351 -0
  137. induscode/sessions/__init__.py +25 -0
  138. induscode/sessions/contract.py +119 -0
  139. induscode/sessions/library.py +350 -0
  140. induscode/settings/__init__.py +47 -0
  141. induscode/settings/contract.py +313 -0
  142. induscode/settings/manager.py +268 -0
  143. induscode/transcript_export/__init__.py +109 -0
  144. induscode/transcript_export/contract.py +522 -0
  145. induscode/transcript_export/publish.py +455 -0
  146. induscode/transcript_export/sgr.py +566 -0
  147. induscode/transcript_export/template.py +319 -0
  148. induscode/transcript_export/theme_bridge.py +325 -0
  149. induscode/window_budget/__init__.py +76 -0
  150. induscode/window_budget/budget/__init__.py +26 -0
  151. induscode/window_budget/budget/estimate.py +273 -0
  152. induscode/window_budget/budget/gate.py +60 -0
  153. induscode/window_budget/budget/slice.py +145 -0
  154. induscode/window_budget/condenser.py +170 -0
  155. induscode/window_budget/contract.py +329 -0
  156. induscode/window_budget/summarize/__init__.py +33 -0
  157. induscode/window_budget/summarize/condense.py +212 -0
  158. induscode/window_budget/summarize/prompt.py +241 -0
  159. induscode/workspace/__init__.py +30 -0
  160. induscode/workspace/brand.py +96 -0
  161. induscode/workspace/locator.py +269 -0
  162. induscode-0.1.0.dist-info/METADATA +97 -0
  163. induscode-0.1.0.dist-info/RECORD +167 -0
  164. induscode-0.1.0.dist-info/WHEEL +4 -0
  165. induscode-0.1.0.dist-info/entry_points.txt +3 -0
  166. induscode-0.1.0.dist-info/licenses/CREDITS.md +22 -0
  167. 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
+ )