@meridiona/meridian-darwin-arm64 1.55.0 → 1.57.0
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.
- package/VERSION +1 -1
- package/bin/meridian +0 -0
- package/package.json +1 -1
- package/services/agents/observability.py +191 -140
- package/services/agents/pm_worklog_update/agents.py +11 -3
- package/services/agents/run_task_linker_mlx.py +284 -78
- package/services/agents/server.py +246 -46
- package/services/observability/dashboards/classifier-debug.json +5 -5
- package/services/observability/dashboards/pm-worklog-debug.json +140 -0
- package/services/pyproject.toml +1 -1
- package/ui.tar.gz +0 -0
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
1.
|
|
1
|
+
1.57.0
|
package/bin/meridian
CHANGED
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@meridiona/meridian-darwin-arm64",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.57.0",
|
|
4
4
|
"description": "Prebuilt Meridian app for macOS arm64 (daemon binary + dashboard + Python services). Installed via @meridiona/meridian.",
|
|
5
5
|
"homepage": "https://github.com/Meridiona/meridian",
|
|
6
6
|
"repository": {
|
|
@@ -3,10 +3,11 @@
|
|
|
3
3
|
A single `setup(agent_name)` call wires up:
|
|
4
4
|
|
|
5
5
|
* an OTel `TracerProvider` with `service.name=agent_name`
|
|
6
|
-
* a `BatchSpanProcessor`
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
daemon's
|
|
6
|
+
* a `BatchSpanProcessor` writing OTLP/HTTP-protobuf spans to the durable
|
|
7
|
+
telemetry spool (the Rust daemon's shipper drains it to OpenObserve)
|
|
8
|
+
* a `LoggerProvider` + spool log handler so every `logging.LogRecord` is also
|
|
9
|
+
spooled (correlated to the active span), mirroring the Rust daemon's
|
|
10
|
+
`OpenTelemetryTracingBridge`
|
|
10
11
|
* W3C `TraceContextTextMapPropagator` as the global propagator so each
|
|
11
12
|
agent can pick up the Rust daemon's `traceparent` and continue the trace
|
|
12
13
|
* `LoggingInstrumentor` so every `logging.LogRecord` carries
|
|
@@ -15,11 +16,11 @@ A single `setup(agent_name)` call wires up:
|
|
|
15
16
|
under `~/.meridian/logs/{agent_name}.jsonl` plus stderr — both ingestable
|
|
16
17
|
by OpenObserve's log pipeline without further parsing.
|
|
17
18
|
|
|
18
|
-
Export
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
the
|
|
22
|
-
env credential is deprecated and ignored
|
|
19
|
+
Export is gated by the SAME `~/.meridian/settings.json` the Rust daemon reads —
|
|
20
|
+
the `otlp_enabled` toggle — so the dashboard Settings page is the single source
|
|
21
|
+
of truth for both processes. Delivery (endpoint + Basic-auth credentials) is
|
|
22
|
+
owned entirely by the Rust shipper; Python only ever writes to the spool. The
|
|
23
|
+
legacy `MERIDIAN_OO_AUTH` env credential is deprecated and ignored.
|
|
23
24
|
|
|
24
25
|
`extract_parent_context(traceparent)` is the helper agents use to continue
|
|
25
26
|
a span emitted by another process — typically the Rust ETL or another
|
|
@@ -28,46 +29,182 @@ agent stage.
|
|
|
28
29
|
Idempotent: calling `setup` twice is a no-op for the second call (returns
|
|
29
30
|
the existing tracer). This matters because both the daemon and the
|
|
30
31
|
single-shot CLI paths funnel through the same module.
|
|
32
|
+
|
|
33
|
+
Spool durability: when `otlp_enabled` is true (regardless of whether
|
|
34
|
+
credentials are present), spans and logs are written atomically to
|
|
35
|
+
`~/.meridian/telemetry/pending/<signal>-<unix_micros>-<seq>.otlp` via
|
|
36
|
+
`SpoolSpanExporter` and `SpoolLogExporter`. The Rust daemon's shipper
|
|
37
|
+
task drains that shared directory to OpenObserve — Python does NOT need
|
|
38
|
+
its own shipper, and credential resolution lives there, not here.
|
|
31
39
|
"""
|
|
32
40
|
from __future__ import annotations
|
|
33
41
|
|
|
34
|
-
import base64
|
|
35
42
|
import json
|
|
36
43
|
import logging
|
|
37
44
|
import logging.handlers
|
|
38
45
|
import os
|
|
39
46
|
import sys
|
|
47
|
+
import threading
|
|
48
|
+
import time
|
|
40
49
|
from pathlib import Path
|
|
41
|
-
from typing import
|
|
50
|
+
from typing import Optional
|
|
42
51
|
|
|
43
52
|
from opentelemetry import trace
|
|
44
53
|
from opentelemetry._logs import set_logger_provider
|
|
45
54
|
from opentelemetry.context import Context
|
|
46
|
-
from opentelemetry.exporter.otlp.proto.http._log_exporter import OTLPLogExporter
|
|
47
|
-
from opentelemetry.exporter.otlp.proto.http.trace_exporter import (
|
|
48
|
-
OTLPSpanExporter,
|
|
49
|
-
)
|
|
50
55
|
from opentelemetry.instrumentation.logging import LoggingInstrumentor
|
|
51
56
|
from opentelemetry.propagate import set_global_textmap
|
|
52
57
|
from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler
|
|
53
|
-
from opentelemetry.sdk._logs.export import BatchLogRecordProcessor
|
|
58
|
+
from opentelemetry.sdk._logs.export import BatchLogRecordProcessor, LogExportResult
|
|
54
59
|
from opentelemetry.sdk.resources import Resource
|
|
55
60
|
from opentelemetry.sdk.trace import TracerProvider
|
|
56
|
-
from opentelemetry.sdk.trace.export import BatchSpanProcessor
|
|
61
|
+
from opentelemetry.sdk.trace.export import BatchSpanProcessor, SpanExportResult
|
|
57
62
|
from opentelemetry.trace.propagation.tracecontext import (
|
|
58
63
|
TraceContextTextMapPropagator,
|
|
59
64
|
)
|
|
60
65
|
from pythonjsonlogger import jsonlogger
|
|
61
66
|
|
|
62
67
|
|
|
68
|
+
# ──────────────────────── Spool exporters ──────────────────────────────────────
|
|
69
|
+
|
|
70
|
+
_spool_seq_lock = threading.Lock()
|
|
71
|
+
_spool_seq = 0
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _next_spool_seq() -> int:
|
|
75
|
+
global _spool_seq
|
|
76
|
+
with _spool_seq_lock:
|
|
77
|
+
val = _spool_seq
|
|
78
|
+
_spool_seq += 1
|
|
79
|
+
return val
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _resolve_telemetry_dir() -> Path:
|
|
83
|
+
"""Mirror of the Rust writer's resolve_telemetry_dir().
|
|
84
|
+
|
|
85
|
+
Precedence: MERIDIAN_TELEMETRY_DIR env → ~/.meridian/telemetry.
|
|
86
|
+
"""
|
|
87
|
+
env = os.environ.get("MERIDIAN_TELEMETRY_DIR", "").strip()
|
|
88
|
+
if env:
|
|
89
|
+
return Path(env).expanduser()
|
|
90
|
+
home = Path.home()
|
|
91
|
+
return home / ".meridian" / "telemetry"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _write_spool(signal: str, payload: bytes) -> None:
|
|
95
|
+
"""Atomically write payload to ~/.meridian/telemetry/pending/.
|
|
96
|
+
|
|
97
|
+
Filename: <signal>-<unix_micros>-<seq>.otlp
|
|
98
|
+
Write via <name>.tmp then rename so the Rust shipper never sees partial files.
|
|
99
|
+
"""
|
|
100
|
+
base = _resolve_telemetry_dir()
|
|
101
|
+
pending = base / "pending"
|
|
102
|
+
pending.mkdir(parents=True, exist_ok=True)
|
|
103
|
+
|
|
104
|
+
micros = int(time.time() * 1_000_000)
|
|
105
|
+
seq = _next_spool_seq()
|
|
106
|
+
filename = f"{signal}-{micros}-{seq}.otlp"
|
|
107
|
+
final_path = pending / filename
|
|
108
|
+
tmp_path = pending / f"{filename}.tmp"
|
|
109
|
+
|
|
110
|
+
try:
|
|
111
|
+
# fsync the tmp file before the rename so a power loss can't leave a
|
|
112
|
+
# rename (metadata) durable while the data blocks are still in page
|
|
113
|
+
# cache — that would surface a truncated .otlp the shipper POSTs (→ a
|
|
114
|
+
# 400). Mirrors the Rust writer's sync_all() + dir fsync.
|
|
115
|
+
with open(tmp_path, "wb") as fh:
|
|
116
|
+
fh.write(payload)
|
|
117
|
+
fh.flush()
|
|
118
|
+
os.fsync(fh.fileno())
|
|
119
|
+
tmp_path.rename(final_path)
|
|
120
|
+
try:
|
|
121
|
+
dir_fd = os.open(str(pending), os.O_RDONLY)
|
|
122
|
+
try:
|
|
123
|
+
os.fsync(dir_fd)
|
|
124
|
+
finally:
|
|
125
|
+
os.close(dir_fd)
|
|
126
|
+
except OSError:
|
|
127
|
+
# Directory fsync is best-effort (not all FS/platforms support it);
|
|
128
|
+
# the tmp-file fsync already guarantees the data is on disk.
|
|
129
|
+
pass
|
|
130
|
+
except Exception as exc:
|
|
131
|
+
logging.getLogger(__name__).warning(
|
|
132
|
+
"telemetry spool write failed — payload dropped",
|
|
133
|
+
extra={"signal": signal, "error": str(exc)},
|
|
134
|
+
)
|
|
135
|
+
# Best-effort cleanup so a failed write never strands a .tmp orphan
|
|
136
|
+
# (the Rust shipper sweeps these, but don't rely on it).
|
|
137
|
+
try:
|
|
138
|
+
tmp_path.unlink(missing_ok=True)
|
|
139
|
+
except OSError as cleanup_exc:
|
|
140
|
+
# Cleanup failure is non-fatal: write already failed and we avoid
|
|
141
|
+
# raising secondary errors from best-effort orphan removal.
|
|
142
|
+
logging.getLogger(__name__).debug(
|
|
143
|
+
"telemetry spool tmp cleanup failed",
|
|
144
|
+
extra={"tmp_path": str(tmp_path), "error": str(cleanup_exc)},
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class SpoolSpanExporter:
|
|
149
|
+
"""Span exporter that writes serialised OTLP payloads to the spool dir.
|
|
150
|
+
|
|
151
|
+
Wraps the SDK's encode_spans() to produce the same wire bytes the real
|
|
152
|
+
OTLPSpanExporter would POST. The Rust shipper drains pending/ to OO.
|
|
153
|
+
"""
|
|
154
|
+
|
|
155
|
+
def export(self, spans): # type: ignore[override]
|
|
156
|
+
try:
|
|
157
|
+
from opentelemetry.exporter.otlp.proto.common.trace_encoder import (
|
|
158
|
+
encode_spans,
|
|
159
|
+
)
|
|
160
|
+
payload = encode_spans(spans).SerializeToString()
|
|
161
|
+
_write_spool("traces", payload)
|
|
162
|
+
except Exception as exc:
|
|
163
|
+
logging.getLogger(__name__).warning(
|
|
164
|
+
"SpoolSpanExporter.export failed", extra={"error": str(exc)}
|
|
165
|
+
)
|
|
166
|
+
return SpanExportResult.SUCCESS
|
|
167
|
+
|
|
168
|
+
def shutdown(self) -> None:
|
|
169
|
+
pass
|
|
170
|
+
|
|
171
|
+
def force_flush(self, timeout_millis: int = 30_000) -> bool:
|
|
172
|
+
return True
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
class SpoolLogExporter:
|
|
176
|
+
"""Log exporter that writes serialised OTLP payloads to the spool dir.
|
|
177
|
+
|
|
178
|
+
Mirrors SpoolSpanExporter for the log signal.
|
|
179
|
+
"""
|
|
180
|
+
|
|
181
|
+
def export(self, log_data): # type: ignore[override]
|
|
182
|
+
try:
|
|
183
|
+
from opentelemetry.exporter.otlp.proto.common._log_encoder import (
|
|
184
|
+
encode_logs,
|
|
185
|
+
)
|
|
186
|
+
payload = encode_logs(log_data).SerializeToString()
|
|
187
|
+
_write_spool("logs", payload)
|
|
188
|
+
except Exception as exc:
|
|
189
|
+
logging.getLogger(__name__).warning(
|
|
190
|
+
"SpoolLogExporter.export failed", extra={"error": str(exc)}
|
|
191
|
+
)
|
|
192
|
+
return LogExportResult.SUCCESS
|
|
193
|
+
|
|
194
|
+
def shutdown(self) -> None:
|
|
195
|
+
pass
|
|
196
|
+
|
|
197
|
+
def force_flush(self, timeout_millis: int = 30_000) -> bool:
|
|
198
|
+
return True
|
|
199
|
+
|
|
200
|
+
|
|
63
201
|
# ──────────────────────── Config ───────────────────────────────────────────────
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
#
|
|
68
|
-
#
|
|
69
|
-
#
|
|
70
|
-
# the daemon and this MLX server pick it up with no env plumbing.
|
|
202
|
+
DEFAULT_LOG_DIR = Path.home() / ".meridian" / "logs"
|
|
203
|
+
# Single source of truth for the export TOGGLE — the SAME file the Rust daemon
|
|
204
|
+
# reads (see `src/observability.rs::resolve_otlp_target`). Delivery credentials
|
|
205
|
+
# live there too: the dashboard Settings page writes here and the Rust shipper
|
|
206
|
+
# picks them up. This process only reads `otlp_enabled` to decide whether to
|
|
207
|
+
# spool — it never delivers, so it needs no endpoint/credentials of its own.
|
|
71
208
|
_SETTINGS_PATH = Path(
|
|
72
209
|
os.environ.get("MERIDIAN_SETTINGS_PATH")
|
|
73
210
|
or (Path.home() / ".meridian" / "settings.json")
|
|
@@ -80,18 +217,6 @@ _INITIALISED: dict[str, trace.Tracer] = {}
|
|
|
80
217
|
_PROCESS_SERVICE_NAME: str | None = None
|
|
81
218
|
# Held so shutdown() can flush log records the same way it flushes spans.
|
|
82
219
|
_LOGGER_PROVIDER: LoggerProvider | None = None
|
|
83
|
-
# One-time guard so an export misconfiguration (enabled-but-no-creds, or a
|
|
84
|
-
# schemeless endpoint) warns once per process instead of on every resolve.
|
|
85
|
-
_WARNED_EXPORT_MISCONFIG: bool = False
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
# ──────────────────────── OTLP target resolution ───────────────────────────────
|
|
89
|
-
class _OtlpTarget(NamedTuple):
|
|
90
|
-
"""Resolved OTLP export target: signal endpoints + Basic-auth header value."""
|
|
91
|
-
|
|
92
|
-
traces_endpoint: str
|
|
93
|
-
logs_endpoint: str
|
|
94
|
-
headers: dict[str, str]
|
|
95
220
|
|
|
96
221
|
|
|
97
222
|
def _load_settings() -> dict[str, object]:
|
|
@@ -104,85 +229,17 @@ def _load_settings() -> dict[str, object]:
|
|
|
104
229
|
return {}
|
|
105
230
|
|
|
106
231
|
|
|
107
|
-
def
|
|
108
|
-
"""
|
|
232
|
+
def _is_otlp_enabled() -> bool:
|
|
233
|
+
"""Return True when the otlp_enabled toggle is on and tracing is not disabled.
|
|
109
234
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
the
|
|
114
|
-
only; the legacy `MERIDIAN_OO_AUTH` env path is deprecated and ignored, the
|
|
115
|
-
same decision the daemon made.
|
|
235
|
+
Deliberately does NOT check credentials — used to gate the spool exporters
|
|
236
|
+
so telemetry is captured even when OO credentials are absent (the Rust
|
|
237
|
+
shipper delivers when they are provided later, and warns once if a user
|
|
238
|
+
leaves the toggle on with no credentials).
|
|
116
239
|
"""
|
|
117
|
-
global _WARNED_EXPORT_MISCONFIG
|
|
118
|
-
|
|
119
240
|
if os.environ.get("MERIDIAN_TRACING_DISABLED", "").lower() in ("1", "true", "yes"):
|
|
120
|
-
return
|
|
121
|
-
|
|
122
|
-
settings = _load_settings()
|
|
123
|
-
if not settings.get("otlp_enabled"):
|
|
124
|
-
return None
|
|
125
|
-
|
|
126
|
-
# Resolve the endpoint up front so we can warn (not silently disable) when
|
|
127
|
-
# export is enabled but unusable. Precedence: settings → env → localhost.
|
|
128
|
-
configured = str(settings.get("otlp_endpoint") or "").strip()
|
|
129
|
-
env_endpoint = (
|
|
130
|
-
os.environ.get("MERIDIAN_OTLP_TRACES_ENDPOINT", "").strip()
|
|
131
|
-
or os.environ.get("MERIDIAN_OTLP_ENDPOINT", "").strip()
|
|
132
|
-
)
|
|
133
|
-
traces_endpoint = configured or env_endpoint or DEFAULT_TRACES_ENDPOINT
|
|
134
|
-
|
|
135
|
-
def _warn_once(msg: str, *args: object) -> None:
|
|
136
|
-
global _WARNED_EXPORT_MISCONFIG
|
|
137
|
-
if not _WARNED_EXPORT_MISCONFIG:
|
|
138
|
-
_WARNED_EXPORT_MISCONFIG = True
|
|
139
|
-
logging.getLogger(__name__).warning(msg, *args)
|
|
140
|
-
|
|
141
|
-
email = str(settings.get("oo_email") or "")
|
|
142
|
-
password = str(settings.get("oo_password") or "")
|
|
143
|
-
if not email or not password:
|
|
144
|
-
# otlp_enabled but no usable credentials → export OFF. Warn once so an
|
|
145
|
-
# env-only (MERIDIAN_OO_AUTH) install that predates the settings.json
|
|
146
|
-
# credential move doesn't go dark silently — mirrors the daemon, which
|
|
147
|
-
# also warns. MERIDIAN_OO_AUTH is no longer read here.
|
|
148
|
-
_warn_once(
|
|
149
|
-
"OpenObserve export enabled but oo_email/oo_password missing in %s — "
|
|
150
|
-
"traces+logs export DISABLED. Set credentials in the dashboard Settings "
|
|
151
|
-
"(the MERIDIAN_OO_AUTH env var is no longer used).",
|
|
152
|
-
_SETTINGS_PATH,
|
|
153
|
-
)
|
|
154
|
-
return None
|
|
155
|
-
# Guard against HTTP header injection / malformed user:password splits —
|
|
156
|
-
# matches the daemon's same-named check.
|
|
157
|
-
if any(c in email for c in "\r\n:") or any(c in password for c in "\r\n"):
|
|
158
|
-
return None
|
|
159
|
-
auth = base64.standard_b64encode(f"{email}:{password}".encode()).decode()
|
|
160
|
-
|
|
161
|
-
# Validate scheme — only http/https are valid OTLP transports. The daemon
|
|
162
|
-
# disables export + warns on a schemeless endpoint; mirror that exactly so the
|
|
163
|
-
# two processes don't disagree on whether export is on.
|
|
164
|
-
if not (traces_endpoint.startswith("http://") or traces_endpoint.startswith("https://")):
|
|
165
|
-
_warn_once(
|
|
166
|
-
"OTLP endpoint %r has no http/https scheme — export DISABLED.",
|
|
167
|
-
traces_endpoint,
|
|
168
|
-
)
|
|
169
|
-
return None
|
|
170
|
-
|
|
171
|
-
# OpenObserve serves logs at the sibling `…/v1/logs` path. Derive it from the
|
|
172
|
-
# traces endpoint by swapping the trailing signal segment so a custom host or
|
|
173
|
-
# base (incl. a trailing slash, e.g. `…/v1/traces/`) carries to BOTH signals —
|
|
174
|
-
# never silently fall back to localhost for logs while traces go remote.
|
|
175
|
-
t = traces_endpoint.rstrip("/")
|
|
176
|
-
if t.endswith("/v1/traces"):
|
|
177
|
-
logs_endpoint = t[: -len("/v1/traces")] + "/v1/logs"
|
|
178
|
-
elif t.endswith("/traces"):
|
|
179
|
-
logs_endpoint = t[: -len("/traces")] + "/logs"
|
|
180
|
-
elif "traces" in t:
|
|
181
|
-
logs_endpoint = t.rsplit("traces", 1)[0] + "logs"
|
|
182
|
-
else:
|
|
183
|
-
logs_endpoint = t + "/v1/logs"
|
|
184
|
-
|
|
185
|
-
return _OtlpTarget(traces_endpoint, logs_endpoint, {"Authorization": f"Basic {auth}"})
|
|
241
|
+
return False
|
|
242
|
+
return bool(_load_settings().get("otlp_enabled"))
|
|
186
243
|
|
|
187
244
|
|
|
188
245
|
# ──────────────────────── Public API ───────────────────────────────────────────
|
|
@@ -207,13 +264,8 @@ def setup(agent_name: str) -> trace.Tracer:
|
|
|
207
264
|
|
|
208
265
|
if _PROCESS_SERVICE_NAME is None:
|
|
209
266
|
_PROCESS_SERVICE_NAME = agent_name
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
# (TOCTOU), leaving traces enabled while logs resolve disabled (or with
|
|
213
|
-
# different creds/endpoint) in the same process.
|
|
214
|
-
target = _resolve_otlp_target()
|
|
215
|
-
_configure_tracing(agent_name, target)
|
|
216
|
-
_configure_logging(agent_name, target)
|
|
267
|
+
_configure_tracing(agent_name)
|
|
268
|
+
_configure_logging(agent_name)
|
|
217
269
|
logging.getLogger(agent_name).info(
|
|
218
270
|
"observability initialised",
|
|
219
271
|
extra={"service.name": agent_name},
|
|
@@ -257,15 +309,14 @@ def extract_parent_context(traceparent: Optional[str]) -> Optional[Context]:
|
|
|
257
309
|
|
|
258
310
|
|
|
259
311
|
# ──────────────────────── Tracing setup ────────────────────────────────────────
|
|
260
|
-
def _configure_tracing(agent_name: str
|
|
312
|
+
def _configure_tracing(agent_name: str) -> None:
|
|
261
313
|
resource = Resource.create({"service.name": agent_name})
|
|
262
314
|
provider = TracerProvider(resource=resource)
|
|
263
315
|
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
)
|
|
268
|
-
provider.add_span_processor(BatchSpanProcessor(exporter))
|
|
316
|
+
# Wire the spool exporter when otlp_enabled is true (even without creds).
|
|
317
|
+
# The Rust shipper drains the shared spool dir when a target is available.
|
|
318
|
+
if _is_otlp_enabled():
|
|
319
|
+
provider.add_span_processor(BatchSpanProcessor(SpoolSpanExporter()))
|
|
269
320
|
|
|
270
321
|
# Set as the global provider. OTel's `set_tracer_provider` warns if
|
|
271
322
|
# someone already configured a provider in-process; we accept that and
|
|
@@ -274,32 +325,32 @@ def _configure_tracing(agent_name: str, target: Optional[_OtlpTarget]) -> None:
|
|
|
274
325
|
set_global_textmap(TraceContextTextMapPropagator())
|
|
275
326
|
|
|
276
327
|
|
|
277
|
-
def _configure_log_export(
|
|
278
|
-
|
|
279
|
-
) -> Optional[logging.Handler]:
|
|
280
|
-
"""Build an OTLP-logs handler so every `log.*` record reaches OpenObserve,
|
|
328
|
+
def _configure_log_export(agent_name: str) -> Optional[logging.Handler]:
|
|
329
|
+
"""Build a spool-logs handler so every `log.*` record reaches OpenObserve,
|
|
281
330
|
correlated to the active span by trace_id/span_id — the Python counterpart
|
|
282
331
|
of the Rust daemon's `OpenTelemetryTracingBridge`.
|
|
283
332
|
|
|
284
333
|
Returns the handler (caller attaches it to root) or `None` when export is
|
|
285
334
|
disabled, in which case logs still go to the JSONL file + stdout/stderr.
|
|
335
|
+
|
|
336
|
+
When `otlp_enabled` is true the spool log exporter is always wired (even
|
|
337
|
+
without credentials) — the Rust shipper handles delivery.
|
|
286
338
|
"""
|
|
287
339
|
global _LOGGER_PROVIDER
|
|
288
340
|
|
|
289
|
-
if
|
|
341
|
+
if not _is_otlp_enabled():
|
|
290
342
|
return None
|
|
291
343
|
|
|
292
344
|
resource = Resource.create({"service.name": agent_name})
|
|
293
345
|
provider = LoggerProvider(resource=resource)
|
|
294
|
-
|
|
295
|
-
provider.add_log_record_processor(BatchLogRecordProcessor(exporter))
|
|
346
|
+
provider.add_log_record_processor(BatchLogRecordProcessor(SpoolLogExporter()))
|
|
296
347
|
set_logger_provider(provider)
|
|
297
348
|
_LOGGER_PROVIDER = provider
|
|
298
349
|
return LoggingHandler(level=logging.NOTSET, logger_provider=provider)
|
|
299
350
|
|
|
300
351
|
|
|
301
352
|
# ──────────────────────── Logging setup ────────────────────────────────────────
|
|
302
|
-
def _configure_logging(agent_name: str
|
|
353
|
+
def _configure_logging(agent_name: str) -> None:
|
|
303
354
|
log_dir = Path(os.environ.get("MERIDIAN_LOG_DIR") or DEFAULT_LOG_DIR)
|
|
304
355
|
log_dir.mkdir(parents=True, exist_ok=True)
|
|
305
356
|
log_path = log_dir / f"{agent_name}.jsonl"
|
|
@@ -359,17 +410,17 @@ def _configure_logging(agent_name: str, target: Optional[_OtlpTarget]) -> None:
|
|
|
359
410
|
root.addHandler(file_h)
|
|
360
411
|
root.addHandler(stdout_h)
|
|
361
412
|
root.addHandler(stderr_h)
|
|
362
|
-
#
|
|
363
|
-
#
|
|
413
|
+
# Spool every record for OpenObserve via OTLP/HTTP logs too, when export is
|
|
414
|
+
# enabled. The OTel LoggingHandler reads the active span context, so each
|
|
364
415
|
# OO log row carries the trace_id/span_id that ties it to the classifier's
|
|
365
416
|
# span waterfall. No-op (None) when OTLP is disabled.
|
|
366
|
-
# The
|
|
417
|
+
# The spool handler already carries service.name via the OTel Resource, so it
|
|
367
418
|
# needs no _ServiceFilter (that would duplicate the attribute on each record).
|
|
368
|
-
otlp_log_h = _configure_log_export(agent_name
|
|
419
|
+
otlp_log_h = _configure_log_export(agent_name)
|
|
369
420
|
if otlp_log_h is not None:
|
|
370
|
-
# Do NOT feed the
|
|
371
|
-
#
|
|
372
|
-
# records which this root handler would try to
|
|
421
|
+
# Do NOT feed the spool handler's OWN transport/encoder logs back into
|
|
422
|
+
# the spool: on a hiccup httpx/urllib3/opentelemetry emit WARNING+
|
|
423
|
+
# records which this root handler would try to spool → more failures (a
|
|
373
424
|
# log→export→log loop). Drop those from THIS handler only — they still
|
|
374
425
|
# reach the file/stderr handlers.
|
|
375
426
|
_otlp_excluded = ("httpx", "httpcore", "urllib3", "grpc", "opentelemetry")
|
|
@@ -62,7 +62,8 @@ def build_synth_agent(
|
|
|
62
62
|
debug_level: 1 or 2 (verbose).
|
|
63
63
|
"""
|
|
64
64
|
from agno.agent import Agent
|
|
65
|
-
|
|
65
|
+
# PII guardrail disabled for now — not needed.
|
|
66
|
+
# from agno.guardrails import PIIDetectionGuardrail
|
|
66
67
|
from agno.skills import LocalSkills, Skills
|
|
67
68
|
|
|
68
69
|
return Agent(
|
|
@@ -98,11 +99,18 @@ def build_synth_agent(
|
|
|
98
99
|
SessionBundleSizeGuard(max_tokens=80_000),
|
|
99
100
|
],
|
|
100
101
|
post_hooks=[
|
|
101
|
-
PIIDetectionGuardrail
|
|
102
|
+
# PIIDetectionGuardrail disabled — it's an input guardrail and was
|
|
103
|
+
# wrongly wired into post_hooks (post-hooks get run_output, not
|
|
104
|
+
# run_input → TypeError). Not needed for now.
|
|
102
105
|
time_spent_sanity_check,
|
|
103
106
|
],
|
|
104
107
|
output_schema=JiraUpdate,
|
|
105
|
-
use_json_mode=
|
|
108
|
+
# use_json_mode=False → agno sends the full JiraUpdate json_schema as the
|
|
109
|
+
# request's response_format (not a bare {"type":"json_object"}). The MLX
|
|
110
|
+
# /v1/chat/completions handler reads that schema and FSM-constrains
|
|
111
|
+
# decoding with outlines, so the reasoning model physically cannot leak
|
|
112
|
+
# chain-of-thought prose instead of the JSON object.
|
|
113
|
+
use_json_mode=False,
|
|
106
114
|
add_datetime_to_context=True,
|
|
107
115
|
add_history_to_context=False,
|
|
108
116
|
markdown=False,
|