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.
Files changed (150) hide show
  1. ph/__init__.py +9 -0
  2. ph/agent/__init__.py +33 -0
  3. ph/agent/inbox.py +215 -0
  4. ph/agent/registry.py +186 -0
  5. ph/agent/types.py +214 -0
  6. ph/agent_loop/__init__.py +35 -0
  7. ph/agent_loop/driver.py +624 -0
  8. ph/agent_loop/invariant.py +83 -0
  9. ph/bundles/__init__.py +63 -0
  10. ph/bundles/base.yaml +335 -0
  11. ph/bundles/headless.yaml +10 -0
  12. ph/cancel.py +96 -0
  13. ph/commands/__init__.py +15 -0
  14. ph/commands/autonomous.py +231 -0
  15. ph/commands/revert.py +192 -0
  16. ph/commands/sandbox.py +359 -0
  17. ph/commands/workspaces.py +371 -0
  18. ph/cordis/__init__.py +114 -0
  19. ph/cordis/catalog.py +170 -0
  20. ph/cordis/context.py +1716 -0
  21. ph/cordis/errors.py +69 -0
  22. ph/cordis/events.py +122 -0
  23. ph/cordis/key.py +57 -0
  24. ph/cordis/loader.py +796 -0
  25. ph/cordis/plugin.py +144 -0
  26. ph/documents.py +83 -0
  27. ph/json.py +379 -0
  28. ph/keys.py +163 -0
  29. ph/lingering.py +404 -0
  30. ph/llm/__init__.py +105 -0
  31. ph/llm/adapter.py +314 -0
  32. ph/llm/assembler.py +206 -0
  33. ph/llm/dimensions.py +163 -0
  34. ph/llm/fake.py +124 -0
  35. ph/llm/media.py +317 -0
  36. ph/llm/replay.py +188 -0
  37. ph/llm/retry.py +124 -0
  38. ph/llm/structured.py +249 -0
  39. ph/llm/types.py +689 -0
  40. ph/orphans.py +309 -0
  41. ph/paths.py +350 -0
  42. ph/persistence/__init__.py +37 -0
  43. ph/persistence/checkpoint_policy.py +83 -0
  44. ph/persistence/families.py +103 -0
  45. ph/persistence/jsonl.py +455 -0
  46. ph/persistence/lease.py +78 -0
  47. ph/persistence/lineage.py +285 -0
  48. ph/persistence/protocol.py +299 -0
  49. ph/persistence/repair.py +260 -0
  50. ph/persistence/turso.py +414 -0
  51. ph/py.typed +0 -0
  52. ph/resources.py +184 -0
  53. ph/seams/__init__.py +5 -0
  54. ph/seams/_names.py +51 -0
  55. ph/seams/_registry.py +222 -0
  56. ph/seams/_restriction.py +42 -0
  57. ph/seams/approval.py +475 -0
  58. ph/seams/attachments.py +596 -0
  59. ph/seams/changes.py +418 -0
  60. ph/seams/code_runtime.py +324 -0
  61. ph/seams/code_runtime_stub.py +89 -0
  62. ph/seams/commands.py +282 -0
  63. ph/seams/compaction.py +292 -0
  64. ph/seams/containment.py +316 -0
  65. ph/seams/credentials.py +112 -0
  66. ph/seams/diagnostics.py +160 -0
  67. ph/seams/fs.py +1093 -0
  68. ph/seams/goals.py +365 -0
  69. ph/seams/invariants.py +284 -0
  70. ph/seams/jobs.py +405 -0
  71. ph/seams/permission_presets.py +184 -0
  72. ph/seams/sandbox.py +870 -0
  73. ph/seams/sandbox_allow.py +82 -0
  74. ph/seams/sandbox_egress.py +467 -0
  75. ph/seams/sandbox_local.py +886 -0
  76. ph/seams/schedule.py +470 -0
  77. ph/seams/schedule_index.py +174 -0
  78. ph/seams/scope_invariant.py +157 -0
  79. ph/seams/settings.py +92 -0
  80. ph/seams/shell.py +161 -0
  81. ph/seams/skills.py +978 -0
  82. ph/seams/skills_invariant.py +59 -0
  83. ph/seams/spill.py +263 -0
  84. ph/seams/subagents.py +1492 -0
  85. ph/seams/subprocess.py +592 -0
  86. ph/seams/telemetry.py +249 -0
  87. ph/seams/telemetry_otel.py +173 -0
  88. ph/seams/token_meter.py +347 -0
  89. ph/seams/topology.py +44 -0
  90. ph/seams/tui_screens.py +242 -0
  91. ph/seams/tui_status.py +204 -0
  92. ph/seams/uploads.py +345 -0
  93. ph/seams/user_questions.py +266 -0
  94. ph/seams/workspace.py +2161 -0
  95. ph/seams/workspace_agentfs.py +721 -0
  96. ph/seams/workspace_git.py +917 -0
  97. ph/seams/workspace_jj.py +1045 -0
  98. ph/seams/workspace_provision.py +345 -0
  99. ph/seams/workspace_scratch.py +131 -0
  100. ph/selectors.py +168 -0
  101. ph/session/__init__.py +101 -0
  102. ph/session/derive.py +67 -0
  103. ph/session/events.py +232 -0
  104. ph/session/folds.py +147 -0
  105. ph/session/invariant.py +46 -0
  106. ph/session/json.py +198 -0
  107. ph/session/known_event_types.py +370 -0
  108. ph/session/request_header.py +141 -0
  109. ph/session/session.py +711 -0
  110. ph/session/store.py +434 -0
  111. ph/session/surface.py +349 -0
  112. ph/system_prompt/__init__.py +25 -0
  113. ph/system_prompt/assembly.py +383 -0
  114. ph/system_prompt/memory.py +201 -0
  115. ph/testing/__init__.py +159 -0
  116. ph/testing/anthropic_wire.py +66 -0
  117. ph/testing/builders.py +600 -0
  118. ph/testing/diagnostics.py +23 -0
  119. ph/testing/folds.py +346 -0
  120. ph/testing/git.py +91 -0
  121. ph/testing/jj.py +82 -0
  122. ph/testing/skills.py +58 -0
  123. ph/testing/stub_sandbox.py +38 -0
  124. ph/testing/stub_subagent.py +96 -0
  125. ph/testing/stub_workspace.py +156 -0
  126. ph/text.py +152 -0
  127. ph/tools/__init__.py +90 -0
  128. ph/tools/batch.py +294 -0
  129. ph/tools/builtin/__init__.py +5 -0
  130. ph/tools/builtin/ask_user.py +128 -0
  131. ph/tools/builtin/attach_tool.py +197 -0
  132. ph/tools/builtin/bash_tool.py +127 -0
  133. ph/tools/builtin/fs_tools.py +279 -0
  134. ph/tools/builtin/subagent_task.py +224 -0
  135. ph/tools/code_mode.py +554 -0
  136. ph/tools/definition.py +739 -0
  137. ph/tools/errors.py +125 -0
  138. ph/tools/invariant.py +58 -0
  139. ph/tools/json_schema.py +296 -0
  140. ph/tools/presentation.py +199 -0
  141. ph/tools/prompt.py +29 -0
  142. ph/tools/registry.py +1167 -0
  143. ph/tools/sdk.py +133 -0
  144. ph/tools/timeout.py +57 -0
  145. ph/wire.py +236 -0
  146. ph_core-0.2.0.dist-info/METADATA +239 -0
  147. ph_core-0.2.0.dist-info/RECORD +150 -0
  148. ph_core-0.2.0.dist-info/WHEEL +4 -0
  149. ph_core-0.2.0.dist-info/entry_points.txt +75 -0
  150. 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
+ }