@meridiona/meridian-darwin-arm64 1.54.1 → 1.56.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 CHANGED
@@ -1 +1 @@
1
- 1.54.1
1
+ 1.56.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.54.1",
3
+ "version": "1.56.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": {
@@ -41,15 +41,19 @@ elif command -v openobserve >/dev/null 2>&1; then
41
41
  fi
42
42
 
43
43
  if [[ -z "${OO_BIN}" ]]; then
44
- echo "→ OpenObserve binary not found — downloading v0.11.0 (last release with arm64 binary)..."
44
+ echo "→ OpenObserve binary not found — downloading v0.90.3..."
45
45
  _oo_arch="$(uname -m)"
46
46
  case "$_oo_arch" in
47
47
  arm64) _oo_arch="arm64" ;;
48
48
  x86_64) _oo_arch="amd64" ;;
49
49
  *) echo "✗ Unsupported arch: $_oo_arch" >&2; exit 1 ;;
50
50
  esac
51
- _oo_ver="v0.11.0"
52
- _oo_url="https://github.com/openobserve/openobserve/releases/download/${_oo_ver}/openobserve-${_oo_ver}-darwin-${_oo_arch}.tar.gz"
51
+ # GitHub release assets were removed for recent versions; binaries now live on
52
+ # the official downloads host. Trace deep-linking (dashboard drilldown into a
53
+ # single trace's spans) needs a modern build, so we pin a current stable.
54
+ # KEEP IN SYNC: the same version is pinned in install.sh — bump both together.
55
+ _oo_ver="v0.90.3"
56
+ _oo_url="https://downloads.openobserve.ai/releases/openobserve/${_oo_ver}/openobserve-${_oo_ver}-darwin-${_oo_arch}.tar.gz"
53
57
  mkdir -p "${HOME}/.openobserve"
54
58
  if curl -fsSL -o "${HOME}/.openobserve/openobserve.tar.gz" "$_oo_url" \
55
59
  && tar -xzf "${HOME}/.openobserve/openobserve.tar.gz" -C "${HOME}/.openobserve" \
@@ -3,8 +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` exporting OTLP/HTTP-protobuf spans to OpenObserve
7
- (`MERIDIAN_OTLP_TRACES_ENDPOINT`, with Basic auth via `MERIDIAN_OO_AUTH`)
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`
8
11
  * W3C `TraceContextTextMapPropagator` as the global propagator so each
9
12
  agent can pick up the Rust daemon's `traceparent` and continue the trace
10
13
  * `LoggingInstrumentor` so every `logging.LogRecord` carries
@@ -13,6 +16,12 @@ A single `setup(agent_name)` call wires up:
13
16
  under `~/.meridian/logs/{agent_name}.jsonl` plus stderr — both ingestable
14
17
  by OpenObserve's log pipeline without further parsing.
15
18
 
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.
24
+
16
25
  `extract_parent_context(traceparent)` is the helper agents use to continue
17
26
  a span emitted by another process — typically the Rust ETL or another
18
27
  agent stage.
@@ -20,42 +29,217 @@ agent stage.
20
29
  Idempotent: calling `setup` twice is a no-op for the second call (returns
21
30
  the existing tracer). This matters because both the daemon and the
22
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.
23
39
  """
24
40
  from __future__ import annotations
25
41
 
42
+ import json
26
43
  import logging
27
44
  import logging.handlers
28
45
  import os
29
46
  import sys
47
+ import threading
48
+ import time
30
49
  from pathlib import Path
31
50
  from typing import Optional
32
51
 
33
52
  from opentelemetry import trace
53
+ from opentelemetry._logs import set_logger_provider
34
54
  from opentelemetry.context import Context
35
- from opentelemetry.exporter.otlp.proto.http.trace_exporter import (
36
- OTLPSpanExporter,
37
- )
38
55
  from opentelemetry.instrumentation.logging import LoggingInstrumentor
39
56
  from opentelemetry.propagate import set_global_textmap
57
+ from opentelemetry.sdk._logs import LoggerProvider, LoggingHandler
58
+ from opentelemetry.sdk._logs.export import BatchLogRecordProcessor, LogExportResult
40
59
  from opentelemetry.sdk.resources import Resource
41
60
  from opentelemetry.sdk.trace import TracerProvider
42
- from opentelemetry.sdk.trace.export import BatchSpanProcessor
61
+ from opentelemetry.sdk.trace.export import BatchSpanProcessor, SpanExportResult
43
62
  from opentelemetry.trace.propagation.tracecontext import (
44
63
  TraceContextTextMapPropagator,
45
64
  )
46
65
  from pythonjsonlogger import jsonlogger
47
66
 
48
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
+
49
201
  # ──────────────────────── Config ───────────────────────────────────────────────
50
- DEFAULT_TRACES_ENDPOINT = "http://localhost:5080/api/default/v1/traces"
51
- DEFAULT_LOGS_ENDPOINT = "http://localhost:5080/api/default/v1/logs"
52
- DEFAULT_LOG_DIR = Path.home() / ".meridian" / "logs"
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.
208
+ _SETTINGS_PATH = Path(
209
+ os.environ.get("MERIDIAN_SETTINGS_PATH")
210
+ or (Path.home() / ".meridian" / "settings.json")
211
+ )
53
212
 
54
213
  _NOISY_LOGGERS = ("urllib3", "httpx", "httpcore", "openai", "botocore")
55
214
 
56
215
  # Track which agents have been configured so a second setup() call is a no-op.
57
216
  _INITIALISED: dict[str, trace.Tracer] = {}
58
217
  _PROCESS_SERVICE_NAME: str | None = None
218
+ # Held so shutdown() can flush log records the same way it flushes spans.
219
+ _LOGGER_PROVIDER: LoggerProvider | None = None
220
+
221
+
222
+ def _load_settings() -> dict[str, object]:
223
+ """Read `~/.meridian/settings.json`; empty dict if absent/unreadable."""
224
+ try:
225
+ with _SETTINGS_PATH.open(encoding="utf-8") as fh:
226
+ data = json.load(fh)
227
+ return data if isinstance(data, dict) else {}
228
+ except (OSError, ValueError):
229
+ return {}
230
+
231
+
232
+ def _is_otlp_enabled() -> bool:
233
+ """Return True when the otlp_enabled toggle is on and tracing is not disabled.
234
+
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).
239
+ """
240
+ if os.environ.get("MERIDIAN_TRACING_DISABLED", "").lower() in ("1", "true", "yes"):
241
+ return False
242
+ return bool(_load_settings().get("otlp_enabled"))
59
243
 
60
244
 
61
245
  # ──────────────────────── Public API ───────────────────────────────────────────
@@ -105,6 +289,12 @@ def shutdown() -> None:
105
289
  if hasattr(provider, "shutdown"):
106
290
  provider.shutdown()
107
291
 
292
+ # Flush queued log records too — BatchLogRecordProcessor drops them on
293
+ # interpreter exit otherwise, the same hazard as spans.
294
+ if _LOGGER_PROVIDER is not None:
295
+ _LOGGER_PROVIDER.force_flush(timeout_millis=5_000)
296
+ _LOGGER_PROVIDER.shutdown()
297
+
108
298
 
109
299
  def extract_parent_context(traceparent: Optional[str]) -> Optional[Context]:
110
300
  """Parse an incoming W3C `traceparent` header into an OTel `Context`.
@@ -123,18 +313,10 @@ def _configure_tracing(agent_name: str) -> None:
123
313
  resource = Resource.create({"service.name": agent_name})
124
314
  provider = TracerProvider(resource=resource)
125
315
 
126
- disabled = os.environ.get("MERIDIAN_TRACING_DISABLED", "").lower() in ("1", "true", "yes")
127
- endpoint = (
128
- os.environ.get("MERIDIAN_OTLP_TRACES_ENDPOINT", "").strip()
129
- or os.environ.get("MERIDIAN_OTLP_ENDPOINT", "").strip()
130
- )
131
- if not disabled and endpoint:
132
- headers: dict[str, str] = {}
133
- auth = os.environ.get("MERIDIAN_OO_AUTH")
134
- if auth:
135
- headers["Authorization"] = f"Basic {auth}"
136
- exporter = OTLPSpanExporter(endpoint=endpoint, headers=headers)
137
- 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()))
138
320
 
139
321
  # Set as the global provider. OTel's `set_tracer_provider` warns if
140
322
  # someone already configured a provider in-process; we accept that and
@@ -143,6 +325,30 @@ def _configure_tracing(agent_name: str) -> None:
143
325
  set_global_textmap(TraceContextTextMapPropagator())
144
326
 
145
327
 
328
+ def _configure_log_export(agent_name: str) -> Optional[logging.Handler]:
329
+ """Build a spool-logs handler so every `log.*` record reaches OpenObserve,
330
+ correlated to the active span by trace_id/span_id — the Python counterpart
331
+ of the Rust daemon's `OpenTelemetryTracingBridge`.
332
+
333
+ Returns the handler (caller attaches it to root) or `None` when export is
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.
338
+ """
339
+ global _LOGGER_PROVIDER
340
+
341
+ if not _is_otlp_enabled():
342
+ return None
343
+
344
+ resource = Resource.create({"service.name": agent_name})
345
+ provider = LoggerProvider(resource=resource)
346
+ provider.add_log_record_processor(BatchLogRecordProcessor(SpoolLogExporter()))
347
+ set_logger_provider(provider)
348
+ _LOGGER_PROVIDER = provider
349
+ return LoggingHandler(level=logging.NOTSET, logger_provider=provider)
350
+
351
+
146
352
  # ──────────────────────── Logging setup ────────────────────────────────────────
147
353
  def _configure_logging(agent_name: str) -> None:
148
354
  log_dir = Path(os.environ.get("MERIDIAN_LOG_DIR") or DEFAULT_LOG_DIR)
@@ -204,6 +410,22 @@ def _configure_logging(agent_name: str) -> None:
204
410
  root.addHandler(file_h)
205
411
  root.addHandler(stdout_h)
206
412
  root.addHandler(stderr_h)
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
415
+ # OO log row carries the trace_id/span_id that ties it to the classifier's
416
+ # span waterfall. No-op (None) when OTLP is disabled.
417
+ # The spool handler already carries service.name via the OTel Resource, so it
418
+ # needs no _ServiceFilter (that would duplicate the attribute on each record).
419
+ otlp_log_h = _configure_log_export(agent_name)
420
+ if otlp_log_h is not None:
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
424
+ # log→export→log loop). Drop those from THIS handler only — they still
425
+ # reach the file/stderr handlers.
426
+ _otlp_excluded = ("httpx", "httpcore", "urllib3", "grpc", "opentelemetry")
427
+ otlp_log_h.addFilter(lambda r: not r.name.startswith(_otlp_excluded))
428
+ root.addHandler(otlp_log_h)
207
429
  root.setLevel(level)
208
430
 
209
431
  for noisy in _NOISY_LOGGERS:
@@ -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
- from agno.guardrails import PIIDetectionGuardrail
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=True,
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,