Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions docs/internal/sandboxes-setup-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,25 @@ repositories.

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

### Task-run log mirroring to PostHog Logs (dogfooding)

Task-run log entries (the JSONL appended to object storage via `TaskRun.append_log`) are also mirrored into the PostHog Logs product,
so runs can be browsed and sampled in the Logs UI instead of fetching S3 blobs.

There is no transport of its own: entries are emitted as structured stdout log lines (`event=task_run_log`),
and the per-cluster OTel collector that already ships all container stdout into the region's internal PostHog project picks them up
(locally, `otel-collector-config.dev.yaml` does the same into your dev logs project).
The collector parses each JSON key into a queryable attribute and turns the emitted `request_id` (the run uuid) into a trace id,
so one run groups as a trace and can be pulled up with an attribute filter on `task_run_id`.

```bash
# Which task origins to mirror (comma-separated). Defaults to signals scouts only.
# Set empty to disable.
TASK_RUN_LOGS_MIRROR_ORIGIN_PRODUCTS=signals_scout
```

Mirroring failures are logged and never break the run's log write.

### How `MODAL_DOCKER` works

When both `SANDBOX_PROVIDER=MODAL_DOCKER` and `LOCAL_POSTHOG_CODE_MONOREPO_ROOT` are set:
Expand Down
10 changes: 10 additions & 0 deletions posthog/settings/temporal.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,16 @@
"TASKS_CREDENTIAL_REFRESH_INITIAL_DELAY_SECONDS", 0, type_cast=int
)

# Mirror persisted task-run logs into the PostHog Logs product (dogfooding).
# Entries appended to a run's S3 JSONL log are also emitted as structured stdout log lines;
# the per-cluster OTel collector already ships container stdout into the region's internal
# PostHog project's Logs, so no transport or credentials are needed here. Only runs whose
# task origin_product is in this list are mirrored — scoped to signals scouts for now;
# widen the list to cover more task origins, or set it empty to disable.
TASK_RUN_LOGS_MIRROR_ORIGIN_PRODUCTS: list[str] = get_list(
os.getenv("TASK_RUN_LOGS_MIRROR_ORIGIN_PRODUCTS", "signals_scout")
)
Comment on lines +85 to +87

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Default mirrored transcripts to opt-in

Fresh evidence in this revision is that the old OTLP env gate is gone: with this default, every unset Cloud deployment mirrors signals_scout run entries, and mirror_entries emits the unredacted agent/message/tool/sandbox body to structlog stdout that the docs say is collected into a region-level internal Logs project. Those run logs were previously only in the task's team-scoped object-storage/API path, so scout runs that include project data, MCP query results, or user text can cross tenant boundaries by default; keep this opt-in for explicitly safe teams/destinations or redact bodies before logging.

Useful? React with 👍 / 👎.


TEMPORAL_LOG_LEVEL_PRODUCE: str = os.getenv("TEMPORAL_LOG_LEVEL_PRODUCE", "DEBUG")
TEMPORAL_EXTERNAL_LOGS_QUEUE_SIZE: int = get_from_env("TEMPORAL_EXTERNAL_LOGS_QUEUE_SIZE", 0, type_cast=int)

Expand Down
148 changes: 148 additions & 0 deletions products/tasks/backend/logic/services/run_log_mirror.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
"""Mirror persisted task-run log entries into the PostHog Logs product via stdout.

Task-run logs are appended to object storage as one ACP notification envelope per line.
In every PostHog cluster an OTel collector daemonset already tails container stdout and
ships JSON log lines into the region's internal PostHog project's Logs product, parsing
each JSON key into a queryable log attribute, `level` into severity, and `request_id`
into a trace id (see `argocd/otel-collector` in the charts repo; `otel-collector-config.dev.yaml`
does the same for local dev). So dogfooding scout-run logs needs no transport of its own:
emitting one structured stdout line per persisted entry is enough.

Each mirrored line carries the run's uuid as `request_id`, so a whole run groups as one
trace in the Logs UI and can be pulled up with a `task_run_id` attribute filter.
"""

import json
from typing import Any

from django.conf import settings

import structlog

logger = structlog.get_logger(__name__)

# The collector truncates whole log lines at 100 KB (`max_log_size`); cap the body well
# below that so run identity attributes and JSON overhead never push a line over.
MAX_BODY_CHARS = 8_000

# Defensive budget per append: origin_product is user-settable on task creation, so a
# hostile append_log request must not be able to flood stdout/the collector with an
# arbitrarily long entry list. Real scout appends are small batches, far below this.
MAX_ENTRIES_PER_CALL = 200

_LOG_METHOD_NAMES = {"info": "info", "warn": "warning", "error": "error"}


def mirroring_enabled(origin_product: str) -> bool:
return origin_product in settings.TASK_RUN_LOGS_MIRROR_ORIGIN_PRODUCTS


def mirror_entries(
entries: list[dict],
*,
team_id: int,
task_id: str,
run_id: str,
origin_product: str,
) -> None:
"""Emit one structured stdout log line per persisted entry."""
if len(entries) > MAX_ENTRIES_PER_CALL:
logger.warning(
"task_run_log_mirror_truncated",
task_run_id=run_id,
dropped=len(entries) - MAX_ENTRIES_PER_CALL,
)
entries = entries[:MAX_ENTRIES_PER_CALL]
for entry in entries:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Medium: Unbounded log amplification

An authenticated user with task:write can append a request containing millions of tiny dictionaries to a team-visible scout run. This loop emits one stdout record per element, allowing a single request to expand into gigabytes of log output and occupy the web worker and collector; enforce a small entry-count or total-output limit before iterating.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Bound synchronous mirrored log batches

When an allowlisted run posts a large catch-up append_log batch, this loop emits one structured stdout line per entry synchronously before append_task_run_log can heartbeat the workflow, while the append-log serializer only rejects empty lists and does not cap entry count. Under log-driver/collector backpressure or a huge malformed batch, the new mirror can tie up the request/worker path and flood stdout even though the docstring calls it fire-and-forget; cap/sample the mirrored entries or move mirroring off this path.

Useful? React with 👍 / 👎.

if not isinstance(entry, dict):
continue
raw_notification = entry.get("notification")
notification: dict = raw_notification if isinstance(raw_notification, dict) else {}
update = _session_update(notification)
session_update = update.get("sessionUpdate") if isinstance(update.get("sessionUpdate"), str) else None
severity = _severity(notification)

fields: dict[str, Any] = {
# `request_id` becomes the record's trace id in the collector, grouping the run.
"request_id": run_id,
"task_run_id": run_id,
Comment on lines +53 to +56

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Set actual trace IDs for mirrored logs

When these stdout lines are ingested by the repo collector path, request_id is only parsed as a JSON attribute: otel-collector-config.dev.yaml has the JSON parser but no transform that copies it into the OTLP trace_id, and Logs stores/filtering by trace uses the first-class trace_id column. In local dev, and any deployment using the same collector contract, all mirrored run entries therefore keep the zero/empty trace id and the advertised “one run groups as a trace” workflow does not work; emit real OTLP log records with traceId or add the collector transform with this change.

Useful? React with 👍 / 👎.

"task_id": task_id,
"team_id": team_id,
"origin_product": origin_product,
"body": _body(notification, session_update),
}
method = notification.get("method")
if isinstance(method, str):
fields["acp_method"] = method
if session_update:
fields["acp_session_update"] = session_update
entry_timestamp = entry.get("timestamp")
if isinstance(entry_timestamp, str):
fields["entry_timestamp"] = entry_timestamp

getattr(logger, _LOG_METHOD_NAMES[severity])("task_run_log", **fields)


def _session_update(notification: dict) -> dict:
params = notification.get("params")
if not isinstance(params, dict):
return {}
update = params.get("update")
return update if isinstance(update, dict) else {}


def _severity(notification: dict) -> str:
if notification.get("method") == "_posthog/error":
return "error"
if notification.get("method") == "_posthog/console":
params = notification.get("params")
level = params.get("level") if isinstance(params, dict) else None
if level in ("warn", "error"):
return level
# No "debug" mapping for thought chunks or debug console lines: the root stdlib log
# level is INFO in production, so a debug line would be filtered before it ever
# reaches stdout and the collector.
return "info"


def _body(notification: dict, session_update: str | None) -> str:
raw_params = notification.get("params")
params: dict = raw_params if isinstance(raw_params, dict) else {}
update = _session_update(notification)

body: str | None = None
if session_update:
text = _extract_text(update.get("content"))
if text is not None:
body = f"[{session_update}] {text}"
elif session_update in ("tool_call", "tool_call_update"):
title = update.get("title") or update.get("toolCallId") or ""
status = update.get("status")
body = f"[{session_update}] {title}" + (f" ({status})" if status else "")
elif notification.get("method") in ("_posthog/console", "_posthog/error"):
message = params.get("message")
if isinstance(message, str):
body = message
elif notification.get("method") == "_posthog/sandbox_output":
stdout = params.get("stdout") or ""
stderr = params.get("stderr") or ""
body = f"[sandbox_output exit={params.get('exitCode')}] {stdout}" + (f"\nstderr: {stderr}" if stderr else "")
elif isinstance(notification.get("result"), dict):
stop_reason = notification["result"].get("stopReason")
if isinstance(stop_reason, str):
body = f"[turn_end] {stop_reason}"

if body is None:
body = json.dumps(notification)
return body[:MAX_BODY_CHARS]


def _extract_text(content: Any) -> str | None:
"""Pull plain text out of an ACP content block (single block or list of blocks)."""
if isinstance(content, dict):
text = content.get("text")
return text if isinstance(text, str) else None
if isinstance(content, list):
parts = [t for t in (_extract_text(block) for block in content) if t]
return "\n".join(parts) if parts else None
return None
31 changes: 31 additions & 0 deletions products/tasks/backend/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -1103,6 +1103,8 @@ def append_log(self, entries: list[dict], *, ttl_days: int | None = DEFAULT_LOG_

object_storage.write(self.log_url, content)

self._mirror_logs_to_posthog_logs(entries)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Mirror event-ingest stream events as well

When sandbox_event_ingest_enabled is true for a run, the workflow skips _relay_sandbox_events and the ingest handler writes accepted agent events directly to TaskRunRedisStream (event_ingest.py), so this append-log hook only mirrors backend-generated calls like progress/console messages and misses the actual session/update/sandbox output stream. In that rollout or state-override context, allowlisted signals_scout runs will show incomplete Logs traces; route the event-ingest writer or another shared stream persistence point through the mirror too.

Useful? React with 👍 / 👎.


if is_new_file and ttl_days is not None:
try:
object_storage.tag(
Expand All @@ -1120,6 +1122,35 @@ def append_log(self, entries: list[dict], *, ttl_days: int | None = DEFAULT_LOG_
error=str(e),
)

def _mirror_logs_to_posthog_logs(self, entries: list[dict]) -> None:
"""Mirror persisted entries into the PostHog Logs product via stdout (dogfooding).

Fire-and-forget: mirroring failures must never break the run's log write.
"""
from products.tasks.backend.logic.services.run_log_mirror import mirror_entries, mirroring_enabled

if not settings.TASK_RUN_LOGS_MIRROR_ORIGIN_PRODUCTS:
return

try:
origin_product = self.task.origin_product
if not mirroring_enabled(origin_product):
return

mirror_entries(
entries,
team_id=self.team_id,
task_id=str(self.task_id),
run_id=str(self.id),
origin_product=origin_product,
)
except Exception as e:
logger.warning(
"task_run.mirror_logs_to_posthog_logs_failed",
task_run_id=str(self.id),
error=str(e),
)

def capture_event(self, event: str, properties: dict | None = None, event_uuid: str | None = None) -> None:
try:
distinct_id = (
Expand Down
Loading