Skip to content

Commit 1d35afa

Browse files
authored
feat(tasks): mirror scout run logs into posthog logs
Incorporates the scout run log mirror from PR #71094 (credit: Andrew Maguire, @andrewm4894) into the agent-run telemetry branch, squashed from the PR's final state so both delivery paths ship together. Persisted task-run log entries are mirrored into the PostHog Logs product, scoped to scout runs. There is no transport of its own: TaskRun.append_log emits one structured stdout line per persisted entry (event=task_run_log) and the per-cluster OTel collector that already tails container stdout ships them into the region's internal project's Logs, parsing JSON keys into queryable attributes and request_id (the run uuid) into a trace id so one run groups as one trace. - run_log_mirror.py translates each ACP JSONL entry: readable bodies for agent messages / tool calls / sandbox output / turn ends (8k char cap, bounded entries per call), severity mapping, and run-identity fields as log attributes - TaskRun.append_log calls the mirror after the S3 write, guarded so any failure is logged and never breaks the run - TASK_RUN_LOGS_MIRROR_ORIGIN_PRODUCTS setting gates which task origins mirror (default signals_scout; empty disables) Complementary to the OTLP run-telemetry export on this branch: the mirror carries full readable bodies for PostHog-authored scout runs into the internal project, while the OTLP path stays metadata-only and covers customer-driven cloud runs. Generated-By: PostHog Code Task-Id: 0c511836-2180-455a-9b58-45df2a0661ec
1 parent 931e6ba commit 1d35afa

5 files changed

Lines changed: 401 additions & 0 deletions

File tree

docs/internal/sandboxes-setup-guide.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,25 @@ repositories.
252252

253253
> **Note:** This only works with `SANDBOX_PROVIDER=docker`.
254254

255+
### Task-run log mirroring to PostHog Logs (dogfooding)
256+
257+
Task-run log entries (the JSONL appended to object storage via `TaskRun.append_log`) are also mirrored into the PostHog Logs product,
258+
so runs can be browsed and sampled in the Logs UI instead of fetching S3 blobs.
259+
260+
There is no transport of its own: entries are emitted as structured stdout log lines (`event=task_run_log`),
261+
and the per-cluster OTel collector that already ships all container stdout into the region's internal PostHog project picks them up
262+
(locally, `otel-collector-config.dev.yaml` does the same into your dev logs project).
263+
The collector parses each JSON key into a queryable attribute and turns the emitted `request_id` (the run uuid) into a trace id,
264+
so one run groups as a trace and can be pulled up with an attribute filter on `task_run_id`.
265+
266+
```bash
267+
# Which task origins to mirror (comma-separated). Defaults to signals scouts only.
268+
# Set empty to disable.
269+
TASK_RUN_LOGS_MIRROR_ORIGIN_PRODUCTS=signals_scout
270+
```
271+
272+
Mirroring failures are logged and never break the run's log write.
273+
255274
### How `MODAL_DOCKER` works
256275

257276
When both `SANDBOX_PROVIDER=MODAL_DOCKER` and `LOCAL_POSTHOG_CODE_MONOREPO_ROOT` are set:

posthog/settings/temporal.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,16 @@
102102
"TASKS_CREDENTIAL_REFRESH_INITIAL_DELAY_SECONDS", 0, type_cast=int
103103
)
104104

105+
# Mirror persisted task-run logs into the PostHog Logs product (dogfooding).
106+
# Entries appended to a run's S3 JSONL log are also emitted as structured stdout log lines;
107+
# the per-cluster OTel collector already ships container stdout into the region's internal
108+
# PostHog project's Logs, so no transport or credentials are needed here. Only runs whose
109+
# task origin_product is in this list are mirrored — scoped to signals scouts for now;
110+
# widen the list to cover more task origins, or set it empty to disable.
111+
TASK_RUN_LOGS_MIRROR_ORIGIN_PRODUCTS: list[str] = get_list(
112+
os.getenv("TASK_RUN_LOGS_MIRROR_ORIGIN_PRODUCTS", "signals_scout")
113+
)
114+
105115
TEMPORAL_LOG_LEVEL_PRODUCE: str = os.getenv("TEMPORAL_LOG_LEVEL_PRODUCE", "DEBUG")
106116
TEMPORAL_EXTERNAL_LOGS_QUEUE_SIZE: int = get_from_env("TEMPORAL_EXTERNAL_LOGS_QUEUE_SIZE", 0, type_cast=int)
107117

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
"""Mirror persisted task-run log entries into the PostHog Logs product via stdout.
2+
3+
Task-run logs are appended to object storage as one ACP notification envelope per line.
4+
In every PostHog cluster an OTel collector daemonset already tails container stdout and
5+
ships JSON log lines into the region's internal PostHog project's Logs product, parsing
6+
each JSON key into a queryable log attribute, `level` into severity, and `request_id`
7+
into a trace id (see `argocd/otel-collector` in the charts repo; `otel-collector-config.dev.yaml`
8+
does the same for local dev). So dogfooding scout-run logs needs no transport of its own:
9+
emitting one structured stdout line per persisted entry is enough.
10+
11+
Each mirrored line carries the run's uuid as `request_id`, so a whole run groups as one
12+
trace in the Logs UI and can be pulled up with a `task_run_id` attribute filter.
13+
"""
14+
15+
import json
16+
from typing import Any
17+
18+
from django.conf import settings
19+
20+
import structlog
21+
22+
logger = structlog.get_logger(__name__)
23+
24+
# The collector truncates whole log lines at 100 KB (`max_log_size`); cap the body well
25+
# below that so run identity attributes and JSON overhead never push a line over.
26+
MAX_BODY_CHARS = 8_000
27+
28+
# Defensive budget per append: origin_product is user-settable on task creation, so a
29+
# hostile append_log request must not be able to flood stdout/the collector with an
30+
# arbitrarily long entry list. Real scout appends are small batches, far below this.
31+
MAX_ENTRIES_PER_CALL = 200
32+
33+
_LOG_METHOD_NAMES = {"info": "info", "warn": "warning", "error": "error"}
34+
35+
36+
def mirroring_enabled(origin_product: str) -> bool:
37+
return origin_product in settings.TASK_RUN_LOGS_MIRROR_ORIGIN_PRODUCTS
38+
39+
40+
def mirror_entries(
41+
entries: list[dict],
42+
*,
43+
team_id: int,
44+
task_id: str,
45+
run_id: str,
46+
origin_product: str,
47+
) -> None:
48+
"""Emit one structured stdout log line per persisted entry."""
49+
if len(entries) > MAX_ENTRIES_PER_CALL:
50+
logger.warning(
51+
"task_run_log_mirror_truncated",
52+
task_run_id=run_id,
53+
dropped=len(entries) - MAX_ENTRIES_PER_CALL,
54+
)
55+
entries = entries[:MAX_ENTRIES_PER_CALL]
56+
for entry in entries:
57+
if not isinstance(entry, dict):
58+
continue
59+
raw_notification = entry.get("notification")
60+
notification: dict = raw_notification if isinstance(raw_notification, dict) else {}
61+
update = _session_update(notification)
62+
session_update = update.get("sessionUpdate") if isinstance(update.get("sessionUpdate"), str) else None
63+
severity = _severity(notification)
64+
65+
fields: dict[str, Any] = {
66+
# `request_id` becomes the record's trace id in the collector, grouping the run.
67+
"request_id": run_id,
68+
"task_run_id": run_id,
69+
"task_id": task_id,
70+
"team_id": team_id,
71+
"origin_product": origin_product,
72+
"body": _body(notification, session_update),
73+
}
74+
method = notification.get("method")
75+
if isinstance(method, str):
76+
fields["acp_method"] = method
77+
if session_update:
78+
fields["acp_session_update"] = session_update
79+
entry_timestamp = entry.get("timestamp")
80+
if isinstance(entry_timestamp, str):
81+
fields["entry_timestamp"] = entry_timestamp
82+
83+
getattr(logger, _LOG_METHOD_NAMES[severity])("task_run_log", **fields)
84+
85+
86+
def _session_update(notification: dict) -> dict:
87+
params = notification.get("params")
88+
if not isinstance(params, dict):
89+
return {}
90+
update = params.get("update")
91+
return update if isinstance(update, dict) else {}
92+
93+
94+
def _severity(notification: dict) -> str:
95+
if notification.get("method") == "_posthog/error":
96+
return "error"
97+
if notification.get("method") == "_posthog/console":
98+
params = notification.get("params")
99+
level = params.get("level") if isinstance(params, dict) else None
100+
if level in ("warn", "error"):
101+
return level
102+
# No "debug" mapping for thought chunks or debug console lines: the root stdlib log
103+
# level is INFO in production, so a debug line would be filtered before it ever
104+
# reaches stdout and the collector.
105+
return "info"
106+
107+
108+
def _body(notification: dict, session_update: str | None) -> str:
109+
raw_params = notification.get("params")
110+
params: dict = raw_params if isinstance(raw_params, dict) else {}
111+
update = _session_update(notification)
112+
113+
body: str | None = None
114+
if session_update:
115+
text = _extract_text(update.get("content"))
116+
if text is not None:
117+
body = f"[{session_update}] {text}"
118+
elif session_update in ("tool_call", "tool_call_update"):
119+
title = update.get("title") or update.get("toolCallId") or ""
120+
status = update.get("status")
121+
body = f"[{session_update}] {title}" + (f" ({status})" if status else "")
122+
elif notification.get("method") in ("_posthog/console", "_posthog/error"):
123+
message = params.get("message")
124+
if isinstance(message, str):
125+
body = message
126+
elif notification.get("method") == "_posthog/sandbox_output":
127+
stdout = params.get("stdout") or ""
128+
stderr = params.get("stderr") or ""
129+
body = f"[sandbox_output exit={params.get('exitCode')}] {stdout}" + (f"\nstderr: {stderr}" if stderr else "")
130+
elif isinstance(notification.get("result"), dict):
131+
stop_reason = notification["result"].get("stopReason")
132+
if isinstance(stop_reason, str):
133+
body = f"[turn_end] {stop_reason}"
134+
135+
if body is None:
136+
body = json.dumps(notification)
137+
return body[:MAX_BODY_CHARS]
138+
139+
140+
def _extract_text(content: Any) -> str | None:
141+
"""Pull plain text out of an ACP content block (single block or list of blocks)."""
142+
if isinstance(content, dict):
143+
text = content.get("text")
144+
return text if isinstance(text, str) else None
145+
if isinstance(content, list):
146+
parts = [t for t in (_extract_text(block) for block in content) if t]
147+
return "\n".join(parts) if parts else None
148+
return None

products/tasks/backend/models.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1669,6 +1669,8 @@ def append_log(self, entries: list[dict], *, ttl_days: int | None = DEFAULT_LOG_
16691669

16701670
object_storage.write(self.log_url, content)
16711671

1672+
self._mirror_logs_to_posthog_logs(entries)
1673+
16721674
if is_new_file and ttl_days is not None:
16731675
try:
16741676
object_storage.tag(
@@ -1686,6 +1688,35 @@ def append_log(self, entries: list[dict], *, ttl_days: int | None = DEFAULT_LOG_
16861688
error=str(e),
16871689
)
16881690

1691+
def _mirror_logs_to_posthog_logs(self, entries: list[dict]) -> None:
1692+
"""Mirror persisted entries into the PostHog Logs product via stdout (dogfooding).
1693+
1694+
Fire-and-forget: mirroring failures must never break the run's log write.
1695+
"""
1696+
from products.tasks.backend.logic.services.run_log_mirror import mirror_entries, mirroring_enabled
1697+
1698+
if not settings.TASK_RUN_LOGS_MIRROR_ORIGIN_PRODUCTS:
1699+
return
1700+
1701+
try:
1702+
origin_product = self.task.origin_product
1703+
if not mirroring_enabled(origin_product):
1704+
return
1705+
1706+
mirror_entries(
1707+
entries,
1708+
team_id=self.team_id,
1709+
task_id=str(self.task_id),
1710+
run_id=str(self.id),
1711+
origin_product=origin_product,
1712+
)
1713+
except Exception as e:
1714+
logger.warning(
1715+
"task_run.mirror_logs_to_posthog_logs_failed",
1716+
task_run_id=str(self.id),
1717+
error=str(e),
1718+
)
1719+
16891720
def effective_rtk(self) -> bool | None:
16901721
"""rtk posture for analytics: the launch-persisted effective value, falling
16911722
back to the user's explicit override for runs that never launched."""

0 commit comments

Comments
 (0)