ph-core 0.2.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.
- ph/__init__.py +9 -0
- ph/agent/__init__.py +33 -0
- ph/agent/inbox.py +215 -0
- ph/agent/registry.py +186 -0
- ph/agent/types.py +214 -0
- ph/agent_loop/__init__.py +35 -0
- ph/agent_loop/driver.py +624 -0
- ph/agent_loop/invariant.py +83 -0
- ph/bundles/__init__.py +63 -0
- ph/bundles/base.yaml +335 -0
- ph/bundles/headless.yaml +10 -0
- ph/cancel.py +96 -0
- ph/commands/__init__.py +15 -0
- ph/commands/autonomous.py +231 -0
- ph/commands/revert.py +192 -0
- ph/commands/sandbox.py +359 -0
- ph/commands/workspaces.py +371 -0
- ph/cordis/__init__.py +114 -0
- ph/cordis/catalog.py +170 -0
- ph/cordis/context.py +1716 -0
- ph/cordis/errors.py +69 -0
- ph/cordis/events.py +122 -0
- ph/cordis/key.py +57 -0
- ph/cordis/loader.py +796 -0
- ph/cordis/plugin.py +144 -0
- ph/documents.py +83 -0
- ph/json.py +379 -0
- ph/keys.py +163 -0
- ph/lingering.py +404 -0
- ph/llm/__init__.py +105 -0
- ph/llm/adapter.py +314 -0
- ph/llm/assembler.py +206 -0
- ph/llm/dimensions.py +163 -0
- ph/llm/fake.py +124 -0
- ph/llm/media.py +317 -0
- ph/llm/replay.py +188 -0
- ph/llm/retry.py +124 -0
- ph/llm/structured.py +249 -0
- ph/llm/types.py +689 -0
- ph/orphans.py +309 -0
- ph/paths.py +350 -0
- ph/persistence/__init__.py +37 -0
- ph/persistence/checkpoint_policy.py +83 -0
- ph/persistence/families.py +103 -0
- ph/persistence/jsonl.py +455 -0
- ph/persistence/lease.py +78 -0
- ph/persistence/lineage.py +285 -0
- ph/persistence/protocol.py +299 -0
- ph/persistence/repair.py +260 -0
- ph/persistence/turso.py +414 -0
- ph/py.typed +0 -0
- ph/resources.py +184 -0
- ph/seams/__init__.py +5 -0
- ph/seams/_names.py +51 -0
- ph/seams/_registry.py +222 -0
- ph/seams/_restriction.py +42 -0
- ph/seams/approval.py +475 -0
- ph/seams/attachments.py +596 -0
- ph/seams/changes.py +418 -0
- ph/seams/code_runtime.py +324 -0
- ph/seams/code_runtime_stub.py +89 -0
- ph/seams/commands.py +282 -0
- ph/seams/compaction.py +292 -0
- ph/seams/containment.py +316 -0
- ph/seams/credentials.py +112 -0
- ph/seams/diagnostics.py +160 -0
- ph/seams/fs.py +1093 -0
- ph/seams/goals.py +365 -0
- ph/seams/invariants.py +284 -0
- ph/seams/jobs.py +405 -0
- ph/seams/permission_presets.py +184 -0
- ph/seams/sandbox.py +870 -0
- ph/seams/sandbox_allow.py +82 -0
- ph/seams/sandbox_egress.py +467 -0
- ph/seams/sandbox_local.py +886 -0
- ph/seams/schedule.py +470 -0
- ph/seams/schedule_index.py +174 -0
- ph/seams/scope_invariant.py +157 -0
- ph/seams/settings.py +92 -0
- ph/seams/shell.py +161 -0
- ph/seams/skills.py +978 -0
- ph/seams/skills_invariant.py +59 -0
- ph/seams/spill.py +263 -0
- ph/seams/subagents.py +1492 -0
- ph/seams/subprocess.py +592 -0
- ph/seams/telemetry.py +249 -0
- ph/seams/telemetry_otel.py +173 -0
- ph/seams/token_meter.py +347 -0
- ph/seams/topology.py +44 -0
- ph/seams/tui_screens.py +242 -0
- ph/seams/tui_status.py +204 -0
- ph/seams/uploads.py +345 -0
- ph/seams/user_questions.py +266 -0
- ph/seams/workspace.py +2161 -0
- ph/seams/workspace_agentfs.py +721 -0
- ph/seams/workspace_git.py +917 -0
- ph/seams/workspace_jj.py +1045 -0
- ph/seams/workspace_provision.py +345 -0
- ph/seams/workspace_scratch.py +131 -0
- ph/selectors.py +168 -0
- ph/session/__init__.py +101 -0
- ph/session/derive.py +67 -0
- ph/session/events.py +232 -0
- ph/session/folds.py +147 -0
- ph/session/invariant.py +46 -0
- ph/session/json.py +198 -0
- ph/session/known_event_types.py +370 -0
- ph/session/request_header.py +141 -0
- ph/session/session.py +711 -0
- ph/session/store.py +434 -0
- ph/session/surface.py +349 -0
- ph/system_prompt/__init__.py +25 -0
- ph/system_prompt/assembly.py +383 -0
- ph/system_prompt/memory.py +201 -0
- ph/testing/__init__.py +159 -0
- ph/testing/anthropic_wire.py +66 -0
- ph/testing/builders.py +600 -0
- ph/testing/diagnostics.py +23 -0
- ph/testing/folds.py +346 -0
- ph/testing/git.py +91 -0
- ph/testing/jj.py +82 -0
- ph/testing/skills.py +58 -0
- ph/testing/stub_sandbox.py +38 -0
- ph/testing/stub_subagent.py +96 -0
- ph/testing/stub_workspace.py +156 -0
- ph/text.py +152 -0
- ph/tools/__init__.py +90 -0
- ph/tools/batch.py +294 -0
- ph/tools/builtin/__init__.py +5 -0
- ph/tools/builtin/ask_user.py +128 -0
- ph/tools/builtin/attach_tool.py +197 -0
- ph/tools/builtin/bash_tool.py +127 -0
- ph/tools/builtin/fs_tools.py +279 -0
- ph/tools/builtin/subagent_task.py +224 -0
- ph/tools/code_mode.py +554 -0
- ph/tools/definition.py +739 -0
- ph/tools/errors.py +125 -0
- ph/tools/invariant.py +58 -0
- ph/tools/json_schema.py +296 -0
- ph/tools/presentation.py +199 -0
- ph/tools/prompt.py +29 -0
- ph/tools/registry.py +1167 -0
- ph/tools/sdk.py +133 -0
- ph/tools/timeout.py +57 -0
- ph/wire.py +236 -0
- ph_core-0.2.0.dist-info/METADATA +239 -0
- ph_core-0.2.0.dist-info/RECORD +150 -0
- ph_core-0.2.0.dist-info/WHEEL +4 -0
- ph_core-0.2.0.dist-info/entry_points.txt +75 -0
- ph_core-0.2.0.dist-info/licenses/LICENSE +21 -0
ph/seams/telemetry.py
ADDED
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
"""`ctx.session_telemetry` — records, redaction, and no span tracer.
|
|
2
|
+
|
|
3
|
+
**The session log is the trace** (§8). There is deliberately no span
|
|
4
|
+
hierarchy: spans would be a second, lossier account of what already exists
|
|
5
|
+
event-by-event in the log, and two accounts of one run diverge.
|
|
6
|
+
|
|
7
|
+
What this seam adds is *export*: a record stream a sink can ship somewhere,
|
|
8
|
+
with one hard ordering rule — **every record passes the
|
|
9
|
+
`session-telemetry/record` redaction waterfall before any sink sees it**. A sink
|
|
10
|
+
registered as a listener alongside redaction could observe an unredacted record
|
|
11
|
+
by winning a race; a sink registered through `add_sink` cannot, because it runs
|
|
12
|
+
after the waterfall settles.
|
|
13
|
+
|
|
14
|
+
Ledger records mirror session events one-to-one with one exception: only the
|
|
15
|
+
*first* `assistant/chunk` per step ships, because a token-by-token export is
|
|
16
|
+
thousands of records saying the same thing, and the first is the one that
|
|
17
|
+
carries the latency signal.
|
|
18
|
+
|
|
19
|
+
@module ph.seams.telemetry
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import logging
|
|
25
|
+
from collections.abc import Callable
|
|
26
|
+
from dataclasses import dataclass, field
|
|
27
|
+
from functools import partial
|
|
28
|
+
from typing import Any, Literal, TypeAlias
|
|
29
|
+
|
|
30
|
+
import anyio
|
|
31
|
+
|
|
32
|
+
from ..cordis import (
|
|
33
|
+
Context,
|
|
34
|
+
Disposer,
|
|
35
|
+
MaybeAwaitable,
|
|
36
|
+
Running,
|
|
37
|
+
events,
|
|
38
|
+
maybe_await,
|
|
39
|
+
plugin,
|
|
40
|
+
running,
|
|
41
|
+
settled_or_none,
|
|
42
|
+
)
|
|
43
|
+
from ..json import as_int, dumps
|
|
44
|
+
from ..keys import SESSION_TELEMETRY, SESSIONS
|
|
45
|
+
from ..paths import default_home_path, write_text_under
|
|
46
|
+
from ..session import Session, SessionEvent, now_ms
|
|
47
|
+
from ..wire import WireModel
|
|
48
|
+
from ._registry import claim_entry
|
|
49
|
+
|
|
50
|
+
__all__ = [
|
|
51
|
+
"SessionTelemetry",
|
|
52
|
+
"SessionTelemetryRecord",
|
|
53
|
+
"apply",
|
|
54
|
+
"ops_record",
|
|
55
|
+
]
|
|
56
|
+
|
|
57
|
+
log = logging.getLogger("ph.seams.telemetry")
|
|
58
|
+
|
|
59
|
+
Channel: TypeAlias = Literal["ledger", "ops"]
|
|
60
|
+
Severity: TypeAlias = Literal["debug", "info", "warn", "error"]
|
|
61
|
+
|
|
62
|
+
events.declare(
|
|
63
|
+
"session-telemetry/record",
|
|
64
|
+
"waterfall",
|
|
65
|
+
owner="ph.seams.telemetry",
|
|
66
|
+
doc="Redaction. Runs before any sink; a listener may rewrite or drop a record.",
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class SessionTelemetryRecord(WireModel):
|
|
71
|
+
"""One exportable record."""
|
|
72
|
+
|
|
73
|
+
channel: Channel
|
|
74
|
+
time: int
|
|
75
|
+
severity: Severity
|
|
76
|
+
attributes: dict[str, Any]
|
|
77
|
+
body: str
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass(frozen=True, slots=True)
|
|
81
|
+
class _Sink:
|
|
82
|
+
"""An exporter and who registered it (P6-29).
|
|
83
|
+
|
|
84
|
+
A sink is a row's body this seam invokes, once per record — the same category
|
|
85
|
+
as a tool's `execute`, and it ran unbound, so an exporter that registered
|
|
86
|
+
anything landed it on the seam and outlived its row."""
|
|
87
|
+
|
|
88
|
+
export: Callable[[SessionTelemetryRecord], MaybeAwaitable[None]]
|
|
89
|
+
by: Running
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@dataclass(slots=True)
|
|
93
|
+
class SessionTelemetry:
|
|
94
|
+
"""The service published as `ctx.session_telemetry`."""
|
|
95
|
+
|
|
96
|
+
ctx: Context
|
|
97
|
+
_sinks: list[_Sink] = field(default_factory=list)
|
|
98
|
+
_exporting: bool = False
|
|
99
|
+
"""Whether a fan-out is already in flight, so a sink cannot feed itself.
|
|
100
|
+
|
|
101
|
+
A sink that records what it just failed to ship — through `ops_record`, or
|
|
102
|
+
any indirection reaching it — would re-enter `record` and fan out to itself,
|
|
103
|
+
a loop with no floor. The rule was a docstring asking each future sink author
|
|
104
|
+
to remember; this is the same rule where it cannot be forgotten. Dropping the
|
|
105
|
+
nested record is the intended answer, not a cost: it is a record *about* the
|
|
106
|
+
export path, made while that path is the thing failing."""
|
|
107
|
+
_last_chunked_step: dict[str, tuple[int, int]] = field(default_factory=dict)
|
|
108
|
+
"""Per session, the step whose first chunk already shipped. One entry per
|
|
109
|
+
session rather than one per step, so it does not grow with the conversation."""
|
|
110
|
+
|
|
111
|
+
def add_sink(
|
|
112
|
+
self,
|
|
113
|
+
sink: Callable[[SessionTelemetryRecord], MaybeAwaitable[None]],
|
|
114
|
+
*,
|
|
115
|
+
scope: Context | None = None,
|
|
116
|
+
) -> Disposer:
|
|
117
|
+
"""Register an exporter. It sees only post-redaction records."""
|
|
118
|
+
by = self.ctx.running_for(scope)
|
|
119
|
+
return claim_entry(by.owner, self._sinks, _Sink(sink, by), label="telemetry.sink")
|
|
120
|
+
|
|
121
|
+
async def record(self, record: SessionTelemetryRecord) -> None:
|
|
122
|
+
"""Redact, then fan out. A dropped record reaches no sink."""
|
|
123
|
+
|
|
124
|
+
async def inner(candidate: SessionTelemetryRecord) -> SessionTelemetryRecord | None:
|
|
125
|
+
return candidate
|
|
126
|
+
|
|
127
|
+
answered = await self.ctx.waterfall("session-telemetry/record", record, inner=inner)
|
|
128
|
+
redacted = settled_or_none("session-telemetry/record", answered, SessionTelemetryRecord)
|
|
129
|
+
if redacted is None or self._exporting:
|
|
130
|
+
return
|
|
131
|
+
self._exporting = True
|
|
132
|
+
try:
|
|
133
|
+
for sink in list(self._sinks):
|
|
134
|
+
try:
|
|
135
|
+
# No target: telemetry is deployment-wide, so both halves are
|
|
136
|
+
# what registration recorded.
|
|
137
|
+
with running(sink.by):
|
|
138
|
+
await maybe_await(sink.export(redacted))
|
|
139
|
+
except Exception:
|
|
140
|
+
log.exception("ph.seams.telemetry: a sink failed")
|
|
141
|
+
finally:
|
|
142
|
+
self._exporting = False
|
|
143
|
+
|
|
144
|
+
def wants(self, session: Session, event: SessionEvent) -> bool:
|
|
145
|
+
"""Whether this event ships — decided synchronously, so a dropped chunk
|
|
146
|
+
costs no task. Only the first `assistant/chunk` per step does."""
|
|
147
|
+
if event.type != "assistant/chunk":
|
|
148
|
+
return True
|
|
149
|
+
step = (as_int(event.data.get("turn")), as_int(event.data.get("step")))
|
|
150
|
+
if self._last_chunked_step.get(session.id) == step:
|
|
151
|
+
return False
|
|
152
|
+
self._last_chunked_step[session.id] = step
|
|
153
|
+
return True
|
|
154
|
+
|
|
155
|
+
async def observe(self, session: Session, event: SessionEvent) -> None:
|
|
156
|
+
"""Mirror one session event onto the ledger channel."""
|
|
157
|
+
await self.record(
|
|
158
|
+
SessionTelemetryRecord(
|
|
159
|
+
channel="ledger",
|
|
160
|
+
time=event.time,
|
|
161
|
+
severity="info",
|
|
162
|
+
attributes={
|
|
163
|
+
"session.id": session.id,
|
|
164
|
+
"event.type": event.type,
|
|
165
|
+
"event.seq": event.seq,
|
|
166
|
+
},
|
|
167
|
+
body=event.type,
|
|
168
|
+
)
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
async def ops(
|
|
172
|
+
self,
|
|
173
|
+
body: str,
|
|
174
|
+
*,
|
|
175
|
+
severity: Severity = "info",
|
|
176
|
+
**attributes: object,
|
|
177
|
+
) -> None:
|
|
178
|
+
"""Record something about the harness rather than the conversation."""
|
|
179
|
+
await self.record(
|
|
180
|
+
SessionTelemetryRecord(
|
|
181
|
+
channel="ops",
|
|
182
|
+
time=now_ms(),
|
|
183
|
+
severity=severity,
|
|
184
|
+
attributes=attributes,
|
|
185
|
+
body=body,
|
|
186
|
+
)
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
async def ops_record(
|
|
191
|
+
ctx: Context,
|
|
192
|
+
body: str,
|
|
193
|
+
*,
|
|
194
|
+
severity: Severity = "info",
|
|
195
|
+
**attributes: object,
|
|
196
|
+
) -> None:
|
|
197
|
+
"""Record something about the harness, if this deployment has the seam (P5-09).
|
|
198
|
+
|
|
199
|
+
The producer's door. A row or a host holding a fact that is *not* a session
|
|
200
|
+
event — an open refused because another process holds the log, a store that
|
|
201
|
+
cannot take the I-5 lease, a workspace provider that broke and left an agent
|
|
202
|
+
uncontained — says it here and never looks for the seam itself. No seam, no
|
|
203
|
+
record and no error: telemetry is optional, and a producer must not fail its
|
|
204
|
+
own work for want of somewhere to put a note about it.
|
|
205
|
+
|
|
206
|
+
A sink that failed and reported its own failure here would fan out to the
|
|
207
|
+
same sink, which is a loop with no floor; `record` refuses a nested fan-out
|
|
208
|
+
for that reason, so reaching this from inside a sink drops the record rather
|
|
209
|
+
than recursing.
|
|
210
|
+
"""
|
|
211
|
+
telemetry = ctx.get(SESSION_TELEMETRY)
|
|
212
|
+
if telemetry is None:
|
|
213
|
+
return
|
|
214
|
+
try:
|
|
215
|
+
await telemetry.ops(body, severity=severity, **attributes)
|
|
216
|
+
except Exception:
|
|
217
|
+
log.exception("ph.seams.telemetry: an ops record could not be made")
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
class Config(WireModel):
|
|
221
|
+
"""Row config for the JSONL telemetry sink."""
|
|
222
|
+
|
|
223
|
+
path: str | None = None
|
|
224
|
+
enabled: bool = True
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
@plugin("session-telemetry", config=Config, inject=[SESSIONS])
|
|
228
|
+
async def apply(ctx: Context, config: Config) -> None:
|
|
229
|
+
"""Mount the telemetry seam and, when enabled, the JSONL sink."""
|
|
230
|
+
telemetry = SessionTelemetry(ctx=ctx)
|
|
231
|
+
ctx.provide(SESSION_TELEMETRY, telemetry)
|
|
232
|
+
|
|
233
|
+
if config.enabled:
|
|
234
|
+
path = default_home_path(config.path, "telemetry.jsonl")
|
|
235
|
+
|
|
236
|
+
async def write(record: SessionTelemetryRecord) -> None:
|
|
237
|
+
await anyio.to_thread.run_sync(
|
|
238
|
+
partial(write_text_under, path, f"{dumps(record.to_wire())}\n", append=True)
|
|
239
|
+
)
|
|
240
|
+
|
|
241
|
+
telemetry.add_sink(write)
|
|
242
|
+
|
|
243
|
+
def on_event(session: Session, event: SessionEvent) -> MaybeAwaitable[None]:
|
|
244
|
+
# The returned coroutine is scheduled by `emit`, never awaited on the
|
|
245
|
+
# append path; a chunk that will not ship returns nothing and costs no
|
|
246
|
+
# task at all.
|
|
247
|
+
return telemetry.observe(session, event) if telemetry.wants(session, event) else None
|
|
248
|
+
|
|
249
|
+
ctx.on("session/event", on_event)
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
"""`session-telemetry-otel` — ship the ledger to an OTel collector (P5-09).
|
|
2
|
+
|
|
3
|
+
A **sink**, registered through `SessionTelemetry.add_sink`, which is the whole
|
|
4
|
+
security argument: `add_sink` runs after the `session-telemetry/record` redaction
|
|
5
|
+
waterfall settles, so an exporter physically cannot observe a record the redactors
|
|
6
|
+
dropped or rewrote. A row that listened on the waterfall event *alongside*
|
|
7
|
+
redaction could see the unredacted form by winning a race; this one has no such
|
|
8
|
+
race to win.
|
|
9
|
+
|
|
10
|
+
**Logs, not spans**, which is §8's decision rather than a shortcut: "the session
|
|
11
|
+
log is the trace", and a span hierarchy would be a second, lossier account of what
|
|
12
|
+
the log already holds event by event. Each record becomes one OTel *log record*,
|
|
13
|
+
carrying the channel and severity pH already assigned.
|
|
14
|
+
|
|
15
|
+
**Optional, and honest when absent.** `opentelemetry-sdk` and the OTLP exporter
|
|
16
|
+
distribution are an extra (`ph-core[otel]`), imported inside `apply` so a
|
|
17
|
+
deployment that never exports does not pay them, and a profile that mounts this
|
|
18
|
+
row without them gets a refusal naming what to install rather than an
|
|
19
|
+
`ImportError` from the loader.
|
|
20
|
+
|
|
21
|
+
**Nothing here half-mounts**: either the row can export or it refuses. Building a
|
|
22
|
+
provider with no exporter would have `phern doctor` report a healthy telemetry sink
|
|
23
|
+
shipping to nowhere.
|
|
24
|
+
|
|
25
|
+
@module ph.seams.telemetry_otel
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
from typing import Any
|
|
31
|
+
|
|
32
|
+
from ..cordis import Context, MountRefusal, plugin
|
|
33
|
+
from ..json import dumps
|
|
34
|
+
from ..keys import SESSION_TELEMETRY
|
|
35
|
+
from ..wire import WireModel
|
|
36
|
+
from .diagnostics import Diagnostic, contribute
|
|
37
|
+
from .telemetry import SessionTelemetryRecord
|
|
38
|
+
|
|
39
|
+
__all__ = ["Config", "apply"]
|
|
40
|
+
|
|
41
|
+
MISSING = (
|
|
42
|
+
"session-telemetry-otel needs the OpenTelemetry SDK and its OTLP exporter, "
|
|
43
|
+
"which are an extra: install `ph-core[otel]`, or remove this row."
|
|
44
|
+
)
|
|
45
|
+
"""Why the row refused, and the two ways out.
|
|
46
|
+
|
|
47
|
+
A refusal rather than a silent no-op: a deployment that mounted an exporter and
|
|
48
|
+
got nothing exported would discover it from the absence of data somewhere else,
|
|
49
|
+
which is the worst place to learn it.
|
|
50
|
+
"""
|
|
51
|
+
|
|
52
|
+
SEVERITY = {"debug": 5, "info": 9, "warn": 13, "error": 17}
|
|
53
|
+
"""pH's four severities as OTel severity numbers.
|
|
54
|
+
|
|
55
|
+
The numbers are OTel's own scale (DEBUG=5, INFO=9, WARN=13, ERROR=17) written
|
|
56
|
+
out rather than imported, so this mapping is readable without the SDK in front
|
|
57
|
+
of you — and so the module's only hard dependency stays inside `apply`. The
|
|
58
|
+
cost of writing them out is that a fifth pH severity could map to nothing; the
|
|
59
|
+
test holds these keys against `get_args(Severity)` so that fails loudly rather
|
|
60
|
+
than exporting the new severity as INFO.
|
|
61
|
+
"""
|
|
62
|
+
|
|
63
|
+
SCALARS = (str, int, float, bool)
|
|
64
|
+
"""What OTel accepts as an attribute value. A tuple at module scope rather than a
|
|
65
|
+
`str | int | float | bool` expression inside `_flat`, which would be evaluated per
|
|
66
|
+
attribute per record.
|
|
67
|
+
"""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class Config(WireModel):
|
|
71
|
+
"""Row config. `endpoint` empty means "whatever the OTEL_* environment says".
|
|
72
|
+
|
|
73
|
+
Deliberately thin: the OTel SDK already reads `OTEL_EXPORTER_OTLP_ENDPOINT`
|
|
74
|
+
and friends, and a second place to configure one exporter is a second place
|
|
75
|
+
for them to disagree. Empty is passed straight through — the exporter's own
|
|
76
|
+
`endpoint or environ.get(...)` gives exactly the unconfigured behavior, so
|
|
77
|
+
there is nothing for this row to branch on.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
service_name: str = "ph"
|
|
81
|
+
endpoint: str = ""
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@plugin("session-telemetry-otel", inject=[SESSION_TELEMETRY], config=Config)
|
|
85
|
+
async def apply(ctx: Context, config: Config) -> None:
|
|
86
|
+
"""Register the exporter as a sink, or refuse with a reason."""
|
|
87
|
+
try:
|
|
88
|
+
from opentelemetry._logs import SeverityNumber
|
|
89
|
+
from opentelemetry.sdk._logs import LoggerProvider
|
|
90
|
+
from opentelemetry.sdk.resources import Resource
|
|
91
|
+
|
|
92
|
+
# Inside the guard, because `_pipeline` imports the *other* half of the
|
|
93
|
+
# extra: a deployment with the SDK but no OTLP exporter refuses here
|
|
94
|
+
# rather than mounting a sink that ships nowhere.
|
|
95
|
+
processor = _pipeline(config)
|
|
96
|
+
except ImportError as error:
|
|
97
|
+
raise MountRefusal(MISSING) from error
|
|
98
|
+
|
|
99
|
+
provider = LoggerProvider(resource=Resource.create({"service.name": config.service_name}))
|
|
100
|
+
provider.add_log_record_processor(processor)
|
|
101
|
+
logger = provider.get_logger("ph.session")
|
|
102
|
+
# Resolved once at mount rather than `SeverityNumber(...)` per record: enum
|
|
103
|
+
# lookup by value goes through `Enum.__call__`, for a table of four that
|
|
104
|
+
# cannot change while the row is mounted.
|
|
105
|
+
severities = {name: SeverityNumber(value) for name, value in SEVERITY.items()}
|
|
106
|
+
|
|
107
|
+
def ship(record: SessionTelemetryRecord) -> None:
|
|
108
|
+
"""One post-redaction record, as one OTel log record.
|
|
109
|
+
|
|
110
|
+
No `try` here: `SessionTelemetry.record` already wraps every sink call,
|
|
111
|
+
so a guard at this level could not change any outcome — it would only
|
|
112
|
+
choose which of two log lines prints, while making the seam's own
|
|
113
|
+
containment unreachable for the one sink that talks to a network. Sink
|
|
114
|
+
health is the seam's to own, and it is already where it can see it.
|
|
115
|
+
"""
|
|
116
|
+
logger.emit(
|
|
117
|
+
timestamp=record.time * 1_000_000,
|
|
118
|
+
body=record.body,
|
|
119
|
+
severity_number=severities[record.severity],
|
|
120
|
+
severity_text=record.severity,
|
|
121
|
+
attributes=_flat({"channel": record.channel, **record.attributes}),
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
# Through `add_sink`, never `ctx.on("session-telemetry/record", …)`: the
|
|
125
|
+
# sink list is fanned out *after* the waterfall settles, so this cannot see
|
|
126
|
+
# a record the redactors have not finished with. A listener could.
|
|
127
|
+
ctx.require(SESSION_TELEMETRY).add_sink(ship, scope=ctx)
|
|
128
|
+
ctx.add_disposer(provider.shutdown, label="telemetry.otel")
|
|
129
|
+
|
|
130
|
+
contribute(
|
|
131
|
+
ctx,
|
|
132
|
+
Diagnostic(
|
|
133
|
+
id="telemetry-otel",
|
|
134
|
+
title="Telemetry export",
|
|
135
|
+
order=70,
|
|
136
|
+
read=lambda: [
|
|
137
|
+
("sink", "opentelemetry"),
|
|
138
|
+
("service.name", config.service_name),
|
|
139
|
+
("endpoint", config.endpoint or "from OTEL_* environment"),
|
|
140
|
+
],
|
|
141
|
+
),
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _pipeline(config: Config) -> Any: # noqa: ANN401
|
|
146
|
+
"""The OTLP exporter behind a batching processor — the row's one test seam.
|
|
147
|
+
|
|
148
|
+
Batching is right in production, where a record per turn must not be a
|
|
149
|
+
request per turn, and wrong in a test, which has to observe the export
|
|
150
|
+
before the batch would have flushed. One substitutable hook rather than two
|
|
151
|
+
(an exporter factory *and* a processor factory) because both existed for
|
|
152
|
+
this single substitution, and the pair had `apply` importing a class purely
|
|
153
|
+
to hand it to a one-line helper.
|
|
154
|
+
"""
|
|
155
|
+
from opentelemetry.exporter.otlp.proto.http._log_exporter import OTLPLogExporter
|
|
156
|
+
from opentelemetry.sdk._logs.export import BatchLogRecordProcessor
|
|
157
|
+
|
|
158
|
+
return BatchLogRecordProcessor(OTLPLogExporter(endpoint=config.endpoint))
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _flat(attributes: dict[str, Any]) -> dict[str, Any]:
|
|
162
|
+
"""OTel attributes are scalars; anything else goes as its JSON text.
|
|
163
|
+
|
|
164
|
+
Flattened here rather than dropped, because a record's attributes are the
|
|
165
|
+
part an operator actually queries on — and a nested value silently omitted
|
|
166
|
+
is a field that looks absent rather than unsupported. The `ph.` prefix is
|
|
167
|
+
applied here and only here, so the caller hands in `channel` rather than
|
|
168
|
+
spelling the convention a second time.
|
|
169
|
+
"""
|
|
170
|
+
return {
|
|
171
|
+
f"ph.{key}": value if isinstance(value, SCALARS) else dumps(value)
|
|
172
|
+
for key, value in attributes.items()
|
|
173
|
+
}
|