Skip to content

Commit 48d972a

Browse files
authored
feat(tasks): inject agent OTLP telemetry into cloud sandboxes (#71100)
1 parent 1438ae3 commit 48d972a

23 files changed

Lines changed: 1102 additions & 44 deletions

docs/internal/sandboxes-setup-guide.md

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,21 @@ SANDBOX_MCP_URL=https://<mcp-8787-subdomain>.ngrok-free.app/mcp
177177

178178
`SANDBOX_MCP_URL` overrides the `host.docker.internal` default (which only resolves from local Docker sandboxes, not Modal). Without it, sandbox agents can't reach the MCP server and lose access to the PostHog `execute-sql`, query, and tool-calling stack.
179179

180+
### Agent run telemetry (optional)
181+
182+
To ship agent-server run metadata to PostHog Logs, set both of the first two; the third additionally produces one APM trace per run (root `task_run` span, a `turn` span per prompt, a `tool_call:<kind>` span per tool call) with trace/span ids stamped on the log records:
183+
184+
```bash
185+
SANDBOX_AGENT_OTEL_LOGS_URL=http://localhost:8000/i/v1/logs # or https://us.i.posthog.com/i/v1/logs
186+
SANDBOX_AGENT_OTEL_LOGS_TOKEN=<project API key of the telemetry project>
187+
SANDBOX_AGENT_OTEL_TRACES_URL=http://localhost:8000/i/v1/traces # optional, enables APM spans
188+
```
189+
190+
In cloud, emission is additionally gated per run by the `tasks-agent-run-otel-telemetry` feature flag (org-targeted, stamped into run state at dispatch; it also gates the scout run-log mirror). `DEBUG` bypasses the flag, so locally these settings are the only switch. They're injected into the sandbox as `POSTHOG_AGENT_OTEL_LOGS_URL`/`_TOKEN`/`POSTHOG_AGENT_OTEL_TRACES_URL` (deliberately not standard `OTEL_*` names, so OTel SDKs in user code don't auto-export into the telemetry project).
191+
The agent-server exports run/turn/tool lifecycle metadata (never message content or tool arguments), tagged with `run_id`/`task_id`/`team_id`/`user_id`/`distinct_id` resource attributes and `service.name=posthog-code-agent`.
192+
Telemetry stays off when either of the first two vars is unset.
193+
For local Docker sandboxes the localhost URLs are rewritten to `host.docker.internal` automatically; local ingestion requires the `capture-logs` service to be running.
194+
180195
### MCP server `.env`
181196

182197
`MODAL_DOCKER` (and the local Docker provider) both depend on the MCP server running at `localhost:8787`. The server reads its config from `services/mcp/.env` — without it, things like `POSTHOG_API_BASE_URL`, the UI-apps token, and analytics keys are missing and the server will either refuse to start or return broken responses to the sandbox.
@@ -237,6 +252,39 @@ repositories.
237252

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

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+
In production 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+
The collector parses each JSON key into a queryable attribute and turns the emitted `request_id` (the run uuid) into a trace id,
263+
so one run groups as a trace and can be pulled up with an attribute filter on `task_run_id`.
264+
265+
```bash
266+
# Which task origins to mirror (comma-separated). Defaults to signals scouts only.
267+
# Set empty to disable.
268+
TASK_RUN_LOGS_MIRROR_ORIGIN_PRODUCTS=signals_scout
269+
```
270+
271+
The mirror also has a **direct OTLP leg** that ships each batch straight to a logs ingest endpoint:
272+
273+
```bash
274+
TASK_RUN_LOGS_MIRROR_OTLP_URL=http://localhost:8000/i/v1/logs # prod: https://us.i.posthog.com/i/v1/logs
275+
TASK_RUN_LOGS_MIRROR_OTLP_TOKEN=<project API key of the internal logs project>
276+
```
277+
278+
The token pins the destination: scout runs execute for customer teams,
279+
but their mirrored transcripts must only ever land in — and bill — PostHog's own internal logs project, never the customer's.
280+
Records arrive under `service.name=task-run-log-mirror` with the run uuid as the trace id.
281+
282+
Locally the direct leg is the only delivery path: `append_log` runs in the host Django process,
283+
and the dev collector (`otel-collector-config.dev.yaml`) only tails docker-compose container stdout,
284+
so without these settings the mirrored lines only show up in the Django phrocs pane.
285+
286+
Mirroring failures are logged and never break the run's log write.
287+
240288
### How `MODAL_DOCKER` works
241289

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

posthog/settings/temporal.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,14 @@
5151
SANDBOX_LLM_GATEWAY_URL: str | None = get_from_env("SANDBOX_LLM_GATEWAY_URL", None, optional=True)
5252
SANDBOX_MCP_URL: str | None = get_from_env("SANDBOX_MCP_URL", None, optional=True)
5353

54+
# OTLP destinations for agent-server run telemetry (PostHog Logs/APM).
55+
# Full ingest URLs (e.g. https://us.i.posthog.com/i/v1/logs and .../i/v1/traces)
56+
# plus the project API key of the telemetry project. Telemetry stays off unless
57+
# URL + token are set; the traces URL additionally enables APM spans.
58+
SANDBOX_AGENT_OTEL_LOGS_URL: str | None = get_from_env("SANDBOX_AGENT_OTEL_LOGS_URL", None, optional=True)
59+
SANDBOX_AGENT_OTEL_LOGS_TOKEN: str | None = get_from_env("SANDBOX_AGENT_OTEL_LOGS_TOKEN", None, optional=True)
60+
SANDBOX_AGENT_OTEL_TRACES_URL: str | None = get_from_env("SANDBOX_AGENT_OTEL_TRACES_URL", None, optional=True)
61+
5462
# client_id of the OAuthApplication used to mint the access token the PostHog setup wizard
5563
# uses when it runs inside a task sandbox (the "run the wizard in the cloud" onboarding path).
5664
# It must be the wizard's own app so the LLM gateway authorizes the token like a normal wizard
@@ -94,6 +102,24 @@
94102
"TASKS_CREDENTIAL_REFRESH_INITIAL_DELAY_SECONDS", 0, type_cast=int
95103
)
96104

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+
115+
# Direct OTLP delivery for the mirror above. The token pins the destination: scout runs
116+
# execute for customer teams, but their mirrored logs must only ever land in (and bill)
117+
# PostHog's own internal logs project — so this is the internal project's API key, never
118+
# derived from the run's team. Point locally at the dev logs ingest to see mirrored runs
119+
# in /logs. Unset disables the direct leg (stdout emission for the collector remains).
120+
TASK_RUN_LOGS_MIRROR_OTLP_URL: str | None = get_from_env("TASK_RUN_LOGS_MIRROR_OTLP_URL", None, optional=True)
121+
TASK_RUN_LOGS_MIRROR_OTLP_TOKEN: str | None = get_from_env("TASK_RUN_LOGS_MIRROR_OTLP_TOKEN", None, optional=True)
122+
97123
TEMPORAL_LOG_LEVEL_PRODUCE: str = os.getenv("TEMPORAL_LOG_LEVEL_PRODUCE", "DEBUG")
98124
TEMPORAL_EXTERNAL_LOGS_QUEUE_SIZE: int = get_from_env("TEMPORAL_EXTERNAL_LOGS_QUEUE_SIZE", 0, type_cast=int)
99125

products/tasks/backend/constants.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@
77
AGENT_PROXY_KEEP_STREAM_OPEN_FEATURE_FLAG = "tasks-agent-proxy-keep-stream-open"
88
MODAL_VM_SANDBOX_FEATURE_FLAG = "tasks-modal-vm-sandbox"
99
MODAL_NETWORK_ALLOWLIST_FEATURE_FLAG = "tasks-modal-network-allowlist"
10+
AGENT_RUN_OTEL_TELEMETRY_FEATURE_FLAG = "tasks-agent-run-otel-telemetry"
11+
# Run-state key the telemetry flag decision is stamped under at dispatch (temporal/client.py).
12+
# Consumers read the stamp, so the decision stays stable for the run's whole lifetime.
13+
AGENT_OTEL_TELEMETRY_STATE_KEY = "agent_otel_telemetry_enabled"
1014

1115

1216
def vm_sandbox_allowed_origin_products(payload: object) -> set[str]:
@@ -365,6 +369,9 @@ def vm_sandbox_allowed_origins(*, distinct_id: str, organization_id: str) -> set
365369
"GH_TOKEN",
366370
"LLM_GATEWAY_URL",
367371
"POSTHOG_RESUME_RUN_ID",
372+
"POSTHOG_AGENT_OTEL_LOGS_URL",
373+
"POSTHOG_AGENT_OTEL_LOGS_TOKEN",
374+
"POSTHOG_AGENT_OTEL_TRACES_URL",
368375
"CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC",
369376
"DISABLE_TELEMETRY",
370377
"DISABLE_ERROR_REPORTING",

products/tasks/backend/facade/api.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
from posthog.models.integration import Integration
3838

3939
from products.tasks.backend.constants import (
40+
AGENT_OTEL_TELEMETRY_STATE_KEY,
4041
MAX_CUSTOM_IMAGES_PER_TEAM,
4142
MAX_CUSTOM_IMAGES_PER_USER,
4243
RESERVED_SANDBOX_ENVIRONMENT_VARIABLE_KEYS,
@@ -1716,6 +1717,12 @@ def _sync_automation_schedule(automation: TaskAutomation) -> None:
17161717
"wizard_head_branch",
17171718
"use_modal_directory_resume_snapshots",
17181719
"use_modal_vm_sandbox",
1720+
# Rollout stamps written once at dispatch by _capture_run_feature_flags; a PATCHable
1721+
# value would let a task controller bypass the org feature flags (for telemetry, that
1722+
# means injecting the internal OTLP capture token into their sandbox and re-enabling
1723+
# the run-log mirror with the rollout off).
1724+
AGENT_OTEL_TELEMETRY_STATE_KEY,
1725+
"sandbox_event_ingest_enabled",
17191726
"snapshot_external_id",
17201727
"snapshot_kind",
17211728
"snapshot_mount_path",

products/tasks/backend/feature_flags.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44

55
import posthoganalytics
66

7+
from products.tasks.backend.constants import AGENT_OTEL_TELEMETRY_STATE_KEY, AGENT_RUN_OTEL_TELEMETRY_FEATURE_FLAG
8+
79
logger = logging.getLogger(__name__)
810

911
NATIVE_STEERING_SIGNALS_FEATURE_FLAG = "tasks-native-steering-signals"
@@ -26,3 +28,32 @@ def is_native_steering_signals_enabled() -> bool:
2628
except Exception:
2729
logger.exception("native_steering_signals_feature_flag_check_failed")
2830
return False
31+
32+
33+
def is_agent_otel_telemetry_enabled(*, distinct_id: str, organization_id: str) -> bool:
34+
"""Org-gated rollout of agent-run OTel telemetry; fail-closed when evaluation fails."""
35+
try:
36+
return bool(
37+
posthoganalytics.feature_enabled(
38+
AGENT_RUN_OTEL_TELEMETRY_FEATURE_FLAG,
39+
distinct_id=distinct_id,
40+
groups={"organization": organization_id},
41+
group_properties={"organization": {"id": organization_id}},
42+
only_evaluate_locally=False,
43+
send_feature_flag_events=False,
44+
)
45+
)
46+
except Exception:
47+
logger.exception("agent_otel_telemetry_flag_check_failed")
48+
return False
49+
50+
51+
def agent_otel_telemetry_enabled_for_state(state: dict | None) -> bool:
52+
"""Per-run telemetry decision, read from the flag value stamped into run state at dispatch.
53+
54+
DEBUG bypasses the flag: the analytics SDK is disabled in local dev, where the
55+
telemetry env settings / mirror settings are themselves the opt-in.
56+
"""
57+
if settings.DEBUG:
58+
return True
59+
return (state or {}).get(AGENT_OTEL_TELEMETRY_STATE_KEY) is True

products/tasks/backend/logic/services/agentsh.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,8 @@ def _port_from_url(url: str | None) -> int | None:
6464
"SANDBOX_API_URL",
6565
"SANDBOX_LLM_GATEWAY_URL",
6666
"SANDBOX_MCP_URL",
67+
"SANDBOX_AGENT_OTEL_LOGS_URL",
68+
"SANDBOX_AGENT_OTEL_TRACES_URL",
6769
)
6870

6971

products/tasks/backend/logic/services/docker_sandbox.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,8 @@
8484
{
8585
"POSTHOG_API_URL",
8686
"POSTHOG_SITE_URL",
87+
"POSTHOG_AGENT_OTEL_LOGS_URL",
88+
"POSTHOG_AGENT_OTEL_TRACES_URL",
8789
"OTEL_EXPORTER_OTLP_LOGS_ENDPOINT",
8890
"OTEL_EXPORTER_OTLP_ENDPOINT",
8991
}

0 commit comments

Comments
 (0)