Instrument PostHog product analytics across the application

23f0d64cf113 · AtlantisPleb · · parent 0fcbbbb81693

Instrument PostHog product analytics across the application

Implements steps 1-7 of the PostHog integration runbook, which
replaces the wizard flow PostHog does not ship for Phoenix:

- OpenAgents.Analytics: one capture boundary with a no-op when no
  project token is configured, standard environment properties, a
  sensitive-key denylist with value truncation and depth bounds, and
  capture failures that never propagate to callers.
- The posthog Hex package starts its supervision tree only when
  OPENAGENTS_POSTHOG_PROJECT_TOKEN is set at boot; error tracking
  stays off as a separate unapproved decision.
- posthog-js bundles through esbuild (no snippet), initializes from
  root-layout data attributes rendered by PostHogBootstrap, captures
  explicit pageviews on load and phx:navigate, identifies accounts
  from a session-written identity, and sends tracing headers.
- AuthController captures the sign-up funnel; chat, memory, issues,
  labels, milestones, projects, computers, voice, forge pushes,
  promotions, and deployments capture their taxonomy events at
  domain-context choke points with optional actor attribution.
- CSP connect-src admits the PostHog ingest and asset hosts.

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified assets/js/app.js
  • added assets/package-lock.json
  • modified assets/package.json
  • modified config/config.exs
  • modified config/runtime.exs
  • modified config/test.exs
  • modified docs/2026-08-21-posthog-integration-runbook.md
  • added lib/openagents/analytics.ex
  • added lib/openagents/analytics/posthog_sink.ex
  • modified lib/openagents/application.ex
  • modified lib/openagents/forge/deployment.ex
  • modified lib/openagents/forge/pushes.ex
  • modified lib/openagents/forge/targets.ex
  • modified lib/openagents/issues.ex
  • modified lib/openagents/labels.ex
  • modified lib/openagents/milestones.ex
  • modified lib/openagents/profile_memory.ex
  • modified lib/openagents/projects.ex
  • modified lib/openagents_web/components/layouts.ex
  • modified lib/openagents_web/components/layouts/root.html.heex
  • modified lib/openagents_web/controllers/auth_controller.ex
  • modified lib/openagents_web/controllers/computer_agent_jobs_controller.ex
  • modified lib/openagents_web/controllers/computers_controller.ex
  • modified lib/openagents_web/controllers/issue_controller.ex
  • modified lib/openagents_web/controllers/label_controller.ex
  • modified lib/openagents_web/controllers/milestone_controller.ex
  • modified lib/openagents_web/controllers/project_controller.ex
  • modified lib/openagents_web/controllers/voice_call_controller.ex
  • modified lib/openagents_web/endpoint.ex
  • modified lib/openagents_web/live/chat_live.ex
  • modified lib/openagents_web/live/issue_index_live.ex
  • modified lib/openagents_web/live/issue_show_live.ex
  • modified lib/openagents_web/live/label_index_live.ex
  • modified lib/openagents_web/live/memory_live.ex
  • modified lib/openagents_web/live/milestone_index_live.ex
  • modified lib/openagents_web/live/project_show_live.ex
  • modified lib/openagents_web/plugs/content_security_policy.ex
  • added lib/openagents_web/plugs/posthog_bootstrap.ex
  • modified lib/openagents_web/router.ex
  • modified mix.exs
  • modified mix.lock
  • added test/openagents/analytics_test.exs

Diff

42 files changed, +1191 -182

assets/js/app.js modified +42

@@ -24,9 +24,51 @@ import {Socket} from "phoenix"

24 24
import {LiveSocket} from "phoenix_live_view"
25 25
import {hooks as colocatedHooks} from "phoenix-colocated/openagents"
26 26
import topbar from "../vendor/topbar"
27
import posthog from "posthog-js"
27 28
import VoiceController from "./voice_controller"
28 29
import PacedTranscript from "./paced_transcript"
29 30
31
// Browser analytics (docs/2026-08-21-posthog-integration-runbook.md). The
32
// root layout carries the boot configuration and the session identity as data
33
// attributes; when capture is unconfigured nothing initializes and no request
34
// to PostHog is made.
35
const initAnalytics = () => {
36
  const attributes = document.body?.dataset
37
  if (!attributes || attributes.posthogEnabled !== "true") return
38
  if (!attributes.posthogToken) return
39
40
  posthog.init(attributes.posthogToken, {
41
    api_host: attributes.posthogApiHost || "https://us.i.posthog.com",
42
    defaults: "2026-05-30",
43
    autocapture: true,
44
    // Pageviews are captured explicitly: once for the initial load here, then
45
    // one per LiveView navigation. Automatic history tracking would double
46
    // count against the phx:navigate listener.
47
    capture_pageview: false,
48
    // Error tracking and session replay are separate, unapproved decisions.
49
    capture_exceptions: false,
50
    disable_session_recording: true,
51
    // Lets server-side events link back to the browser session. Hostname only.
52
    tracing_headers: [window.location.hostname],
53
  })
54
55
  posthog.capture("$pageview", { $current_url: window.location.href })
56
57
  window.addEventListener("phx:navigate", ({ detail: { href } }) => {
58
    posthog.capture("$pageview", { $current_url: href })
59
  })
60
61
  const distinctId = attributes.posthogDistinctId
62
  if (distinctId && !sessionStorage.getItem("posthog:identified")) {
63
    posthog.identify(distinctId, {
64
      github_login: attributes.posthogLogin || undefined,
65
    })
66
    sessionStorage.setItem("posthog:identified", "true")
67
  }
68
}
69
70
initAnalytics()
71
30 72
const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
31 73
const liveSocket = new LiveSocket("/live", Socket, {
32 74
  longPollFallbackMs: 2500,
assets/package-lock.json added +129

@@ -0,0 +1,129 @@

1
{
2
  "name": "openagents-assets",
3
  "lockfileVersion": 3,
4
  "requires": true,
5
  "packages": {
6
    "": {
7
      "name": "openagents-assets",
8
      "dependencies": {
9
        "posthog-js": "^1.418.10"
10
      }
11
    },
12
    "node_modules/@posthog/browser-common": {
13
      "version": "0.5.0",
14
      "resolved": "https://registry.npmjs.org/@posthog/browser-common/-/browser-common-0.5.0.tgz",
15
      "integrity": "sha512-8DaxVZS1bQPbA514RePurLNbYjei3P4jhnC206DwVv5XThmZM3QdlsXenI2ujE3pLbgQ79hYn9o1Kda8I3WK/Q==",
16
      "license": "MIT",
17
      "dependencies": {
18
        "@posthog/core": "^1.47.0",
19
        "@posthog/types": "^1.402.2"
20
      }
21
    },
22
    "node_modules/@posthog/core": {
23
      "version": "1.48.8",
24
      "resolved": "https://registry.npmjs.org/@posthog/core/-/core-1.48.8.tgz",
25
      "integrity": "sha512-LAOBOjMQrQmgcbZxnubl74l2GQd5OWqxCJJ8mzcWdZRWbfO6Y/A6E3fqWjDtm68hY4LxC/bbopG60cxZW8cfWw==",
26
      "license": "MIT",
27
      "dependencies": {
28
        "@posthog/types": "^1.405.1"
29
      }
30
    },
31
    "node_modules/@posthog/types": {
32
      "version": "1.405.1",
33
      "resolved": "https://registry.npmjs.org/@posthog/types/-/types-1.405.1.tgz",
34
      "integrity": "sha512-JvaR4ChUKUk7qSTG58vKN2Br6es9riFF5mvlu7YGcwbB476vfyTO0o/TBh+zE88QhsfxnE5Tr/jKxI+e1v3Q5A==",
35
      "license": "MIT"
36
    },
37
    "node_modules/@types/trusted-types": {
38
      "version": "2.0.7",
39
      "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
40
      "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
41
      "license": "MIT",
42
      "optional": true
43
    },
44
    "node_modules/core-js": {
45
      "version": "3.50.0",
46
      "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.50.0.tgz",
47
      "integrity": "sha512-BRWgOLKkFeCgRudR6zrs8p9XJZcE14grzKMMssoYrk6krtuEZ7MTKPIY5RzOnqsEKIR9kst7wNzphttraT+Yqw==",
48
      "hasInstallScript": true,
49
      "license": "MIT",
50
      "engines": {
51
        "node": "*"
52
      },
53
      "funding": {
54
        "type": "opencollective",
55
        "url": "https://opencollective.com/core-js"
56
      }
57
    },
58
    "node_modules/dompurify": {
59
      "version": "3.4.14",
60
      "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.14.tgz",
61
      "integrity": "sha512-dVoH9z+MY+C9IilgGCk3YfFqjLi3fChm2OiKJMzh6axrJ5qwxqWaZamgmHrpv22CN/KdbZJuGEGgfQoL00LTdg==",
62
      "license": "(MPL-2.0 OR Apache-2.0)",
63
      "optionalDependencies": {
64
        "@types/trusted-types": "^2.0.7"
65
      }
66
    },
67
    "node_modules/fflate": {
68
      "version": "0.4.9",
69
      "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.4.9.tgz",
70
      "integrity": "sha512-zdxgIEddhfsyCaWpJ2SdXEP8ZMrKJ6+5jl4OupODcywU0IhRk6gdXuVGcPICyfx2H97hVK7xmJtRLPjkxAX8Vw==",
71
      "license": "MIT"
72
    },
73
    "node_modules/posthog-js": {
74
      "version": "1.418.10",
75
      "resolved": "https://registry.npmjs.org/posthog-js/-/posthog-js-1.418.10.tgz",
76
      "integrity": "sha512-XMvmmnuFoesSPjFo+wvzXkvuzYqIho8CVqxugG1Wt768offFARYNZDd1/xWfm9vk/pJTJ4RhUEHRzLpggmGx9Q==",
77
      "license": "(Apache-2.0 AND MIT)",
78
      "dependencies": {
79
        "@posthog/browser-common": "^0.5.0",
80
        "@posthog/core": "^1.48.8",
81
        "@posthog/types": "^1.405.1",
82
        "core-js": "^3.49.0",
83
        "dompurify": "^3.4.13",
84
        "fflate": "^0.4.8",
85
        "preact": "^10.29.3",
86
        "query-selector-shadow-dom": "^1.0.1",
87
        "web-vitals": "^5.3.0",
88
        "web-vitals-soft-navs": "npm:web-vitals@6.0.0"
89
      }
90
    },
91
    "node_modules/preact": {
92
      "version": "10.29.8",
93
      "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.8.tgz",
94
      "integrity": "sha512-ej2aVZ+vZ8WO7tvlQWRM9N63A0KzF9q4mWJfDUHgYaIofWY9hu74QdnQrjoPMmZi2/nZ5gN0bJCQF49xQqx09Q==",
95
      "license": "MIT",
96
      "funding": {
97
        "type": "opencollective",
98
        "url": "https://opencollective.com/preact"
99
      },
100
      "peerDependencies": {
101
        "preact-render-to-string": ">=5"
102
      },
103
      "peerDependenciesMeta": {
104
        "preact-render-to-string": {
105
          "optional": true
106
        }
107
      }
108
    },
109
    "node_modules/query-selector-shadow-dom": {
110
      "version": "1.0.1",
111
      "resolved": "https://registry.npmjs.org/query-selector-shadow-dom/-/query-selector-shadow-dom-1.0.1.tgz",
112
      "integrity": "sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==",
113
      "license": "MIT"
114
    },
115
    "node_modules/web-vitals": {
116
      "version": "5.3.0",
117
      "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-5.3.0.tgz",
118
      "integrity": "sha512-q6LWsLatGYZp5VGBIOvbTj6JBV2nOmC8KvWztXBmwJcfFAzhwKwbOxhUH306XY3CcaZDUlSmSuNPBsCn0bFu+g==",
119
      "license": "Apache-2.0"
120
    },
121
    "node_modules/web-vitals-soft-navs": {
122
      "name": "web-vitals",
123
      "version": "6.0.0",
124
      "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-6.0.0.tgz",
125
      "integrity": "sha512-Guaibvy/+uNtL6Bsu4jmMJGzuSl91oeRH5iO9pPRbYftnFUr3yqT1TUNX/OE4o9HexuEMU3Kb/Wg7iKhlffZUA==",
126
      "license": "Apache-2.0"
127
    }
128
  }
129
}
assets/package.json modified +3

@@ -3,5 +3,8 @@

3 3
  "private": true,
4 4
  "scripts": {
5 5
    "test": "node --test test/*.mjs"
6
  },
7
  "dependencies": {
8
    "posthog-js": "^1.418.10"
6 9
  }
7 10
}
config/config.exs modified +13

@@ -49,6 +49,8 @@ config :openagents,

49 49
  computer_controller_enabled: false,
50 50
  machine_token_ttl_seconds: 2_592_000,
51 51
  coding_jobs_dir: "/var/lib/openagents/coding-jobs",
52
  posthog_project_token: nil,
53
  posthog_api_host: "https://us.i.posthog.com",
52 54
  work_workers_enabled: false,
53 55
  work: [enabled: false],
54 56
  scv_codex: [

@@ -271,6 +273,17 @@ config :phoenix_live_view,

271 273
# at the `config/runtime.exs`.
272 274
config :openagents, OpenAgents.Mailer, adapter: Swoosh.Adapters.Local
273 275
276
# Product analytics (docs/2026-08-21-posthog-integration-runbook.md). The
277
# default supervisor stays off; OpenAgents.Application starts PostHog.Supervisor
278
# only when a project token was configured at boot. Error tracking is a
279
# separate, unapproved decision, so exception capture is off.
280
config :posthog,
281
  enable: false,
282
  api_host: "https://us.i.posthog.com",
283
  api_key: nil,
284
  in_app_otp_apps: [:openagents],
285
  enable_error_tracking: false
286
274 287
# Configure esbuild (the version is required)
275 288
config :esbuild,
276 289
  version: "0.25.4",
config/runtime.exs modified +15

@@ -427,6 +427,21 @@ if config_env() == :prod and runtime_role == :scv do

427 427
end
428 428
429 429
if runtime_role == :web do
430
  # Product analytics. An absent token disables capture entirely: no PostHog
431
  # supervision tree starts and OpenAgents.Analytics becomes a no-op.
432
  posthog_project_token = optional_text.("OPENAGENTS_POSTHOG_PROJECT_TOKEN")
433
  posthog_api_host = optional_text.("OPENAGENTS_POSTHOG_API_HOST")
434
435
  config :openagents,
436
    posthog_project_token: posthog_project_token,
437
    posthog_api_host: posthog_api_host || "https://us.i.posthog.com"
438
439
  config :posthog, api_key: posthog_project_token
440
441
  if posthog_api_host do
442
    config :posthog, api_host: posthog_api_host
443
  end
444
430 445
  github_oauth = Application.get_env(:openagents, :github_oauth, [])
431 446
432 447
  github_oauth =
config/test.exs modified +4

@@ -35,6 +35,10 @@ config :openagents, :provider, OpenAgents.Providers.Test

35 35
config :openagents, :voice_call_provider, OpenAgents.Voice.TestCallProvider
36 36
config :openagents, :voice_sideband_provider, OpenAgents.Voice.TestSidebandProvider
37 37
38
# No project token is configured in tests, so OpenAgents.Analytics is a no-op.
39
# test_mode additionally drops any event that reaches the package directly.
40
config :posthog, test_mode: true
41
38 42
# We don't run a server during test. If one is required,
39 43
# you can enable the server option below.
40 44
config :openagents, OpenAgentsWeb.Endpoint,
docs/2026-08-21-posthog-integration-runbook.md modified +91 -107

@@ -2,13 +2,19 @@

2 2
3 3
Date: 2026-08-21
4 4
5
Status: Proposed
5
Status: Implemented in code; staging enablement, dashboards, and live verification remain
6 6
7 7
PostHog ships an agentic installer, the [AI wizard](https://github.com/PostHog/wizard), that wires PostHog into a codebase end to end. The wizard does not support Phoenix or Elixir: the framework is registered as "coming soon" in PostHog's own documentation, so running `npx @posthog/wizard` against this repository would not produce a usable integration.
8 8
9 9
This document is the manual replacement. Part 1 records what the wizard does so we know the full surface we are replicating. Part 2 turns that into a concrete runbook for this Phoenix application: server-side capture from Elixir, browser analytics through our asset bundle, pageviews that survive LiveView navigation, user identification, and an event taxonomy covering every product surface. Session replay and self-driving are out of scope.
10 10
11
Use this document as the single checklist for the integration work. Nothing in it requires the wizard.
11
Steps 1 through 7 of part 2 are implemented. What remains is operational:
12
13
1. Set `OPENAGENTS_POSTHOG_PROJECT_TOKEN` (and optionally `OPENAGENTS_POSTHOG_API_HOST`) in the staging environment.
14
2. Build the starter dashboards through the PostHog MCP (step 8).
15
3. Run the verification checklist against a live staging deployment (step 9).
16
17
Use this document as the single checklist for that work. Nothing in it requires the wizard.
12 18
13 19
## Part 1: What the PostHog wizard does
14 20

@@ -90,125 +96,100 @@ Out of scope for this effort:

90 96
2. Confirm the ingest host: `https://us.i.posthog.com`.
91 97
3. Decide the rollout environment. Staging first; production remains locked behind the existing staging gate, so the integration lands in staging configuration before any production decision.
92 98
93
### Step 1: Route credentials through runtime configuration
99
### Step 1: Route credentials through runtime configuration (implemented)
94 100
95
Follow the `OpenAgents.RuntimeConfig` pattern documented in [runtime-configuration.md](runtime-configuration.md):
101
The settings follow the same optional-text pattern the rest of `config/runtime.exs` uses for optional credentials; they are not part of the strict `OpenAgents.RuntimeConfig` validator, because analytics must never block a boot:
96 102
97 103
| Setting | Requirement |
98 104
| --- | --- |
99
| `OPENAGENTS_POSTHOG_PROJECT_TOKEN` | `phc_...` public project token; empty disables all capture |
100
| `OPENAGENTS_POSTHOG_API_HOST` | Defaults to `https://us.i.posthog.com` |
105
| `OPENAGENTS_POSTHOG_PROJECT_TOKEN` | `phc_...` public project token; absent or empty disables all capture |
106
| `OPENAGENTS_POSTHOG_API_HOST` | Optional; defaults to `https://us.i.posthog.com` |
101 107
102
When the token is empty, the application boots normally with capture fully disabled. This keeps local development and tests free of network calls without extra flags.
108
When the token is absent, the application boots normally with capture fully disabled: no PostHog supervision tree starts and every capture call is a no-op. Local development and tests stay free of network calls without extra flags.
103 109
104
### Step 2: Add the Elixir SDK
110
### Step 2: The Elixir SDK (implemented)
105 111
106
Add the dependency in `mix.exs`:
107
108
```elixir
109
{:posthog, "~> 2.0"}
110
```
111
112
Configure it in `config/config.exs` with values supplied at runtime:
112
`mix.exs` carries `{:posthog, "~> 2.0"}`. Configuration lives in `config/config.exs` with values supplied at runtime:
113 113
114 114
```elixir
115 115
config :posthog,
116
  enable: true,
117
  api_host: {:system, "OPENAGENTS_POSTHOG_API_HOST", "https://us.i.posthog.com"},
118
  api_key: {:system, "OPENAGENTS_POSTHOG_PROJECT_TOKEN", nil},
116
  enable: false,
117
  api_host: "https://us.i.posthog.com",
118
  api_key: nil,
119 119
  in_app_otp_apps: [:openagents],
120 120
  enable_error_tracking: false
121 121
```
122 122
123
Notes:
123
Deviations from a plain install, all deliberate:
124 124
125
- The package's default supervisor stays off. `OpenAgents.Application` starts `{PostHog.Supervisor, config}` only when a project token was configured at boot, so an unconfigured environment runs zero PostHog processes.
125 126
- `enable_error_tracking: false` keeps `$exception` capture off until error tracking is a separate approved decision.
126
- In `config/test.exs`, set `test_mode: true` so events are dropped instead of sent.
127
- `config/test.exs` sets `test_mode: true` so events are dropped instead of sent.
127 128
- The package batches events and flushes them from its own supervision tree; captures do not block request handling.
128 129
129
### Step 3: Create one capture boundary
130
### Step 3: One capture boundary (implemented)
130 131
131
Add a single wrapper module, for example `OpenAgents.Analytics`, and route every server-side capture through it. The wrapper exists to enforce policy in one place:
132
Every server-side event goes through `OpenAgents.Analytics.capture/3`. The boundary enforces policy in one place:
132 133
133
- No-op when the project token is unset.
134
- Merge standard properties onto every event: `environment` (from `OPENAGENTS_ENVIRONMENT`), `app_version`, and `surface` (`web`, `api`, `live`).
135
- Never raise: a capture failure must not fail the caller. Rescue, log, and continue.
136
- Redact by default: reject property keys matching a denylist (`token`, `secret`, `password`, `ciphertext`, `credential`) and drop oversized values.
134
- No-op when the project token is unset (including blank strings).
135
- Merges standard properties onto every event: `environment` (from the runtime environment), `app_revision`, and `surface` (`server` unless the caller passes one).
136
- Drops property keys on a sensitive denylist (`token`, `secret`, `password`, `ciphertext`, `credential`, plus `_token`/`_secret`/... suffixes), truncates oversized values to `[truncated]`, bounds maps and lists by depth and count, and drops structs.
137
- Never raises: a sink failure logs `analytics_capture_failed` and returns `:ok`.
137 138
138
```elixir
139
OpenAgents.Analytics.capture("user_signed_up", distinct_id, %{
140
  github_login: login
141
})
142
```
139
Identity helpers live on the same module:
143 140
144
Passing `distinct_id` explicitly beats relying on process context. The package supports `PostHog.set_context/1` via logger metadata when explicit passing is awkward, but explicit arguments keep call sites greppable.
141
- `Analytics.distinct_id(user_or_id)` derives the canonical `user_<uuid>` ID; already-prefixed values pass through unchanged.
142
- `Analytics.system_distinct_id(surface)` gives operational events a stable synthetic person per surface (`system_forge`).
143
- `Analytics.browser_distinct_id(conn)` reads the `X-PostHog-Distinct-Id` tracing header when present, falling back to `"anonymous"`.
145 144
146
### Step 4: Add posthog-js to the browser bundle
145
A test sink (`Application.put_env(:openagents, :analytics_sink, ...)`) lets tests observe captures without the network; see `test/openagents/analytics_test.exs`.
147 146
148
Install into the existing asset pipeline:
147
### Step 4: posthog-js in the browser bundle (implemented)
149 148
150
```console
151
npm install --prefix assets posthog-js
152
```
149
`posthog-js` is installed into `assets/package.json` and bundled into `app.js` by esbuild — no snippet tag, so the CSP script policy is untouched. `OpenAgentsWeb.Plugs.PostHogBootstrap` assigns the boot configuration and session identity, and the root layout renders them as body data attributes; `app.js` initializes only when `data-posthog-enabled="true"`.
153 150
154
Initialize in `assets/js/app.js`:
151
Initialization options, as shipped:
155 152
156 153
```javascript
157
import posthog from "posthog-js"
158
159
if (window.POSTHOG_CONFIG.enabled) {
160
  posthog.init(window.POSTHOG_CONFIG.token, {
161
    api_host: window.POSTHOG_CONFIG.api_host,
162
    defaults: '2026-05-30',
163
    autocapture: true,
164
  })
165
}
166
```
167
168
Inject the config from the root layout so no token is hardcoded in JS:
169
170
```heex
171
<body data-posthog-enabled={@posthog_enabled}
172
      data-posthog-token={@posthog_token}
173
      data-posthog-api-host={@posthog_api_host}>
154
posthog.init(token, {
155
  api_host: host,
156
  defaults: "2026-05-30",
157
  autocapture: true,
158
  capture_pageview: false,        // pageviews are explicit; see step 5
159
  capture_exceptions: false,      // error tracking is out of scope
160
  disable_session_recording: true, // replay is out of scope
161
  tracing_headers: [window.location.hostname], // links server events to sessions
162
})
174 163
```
175 164
176
Read the data attributes in `app.js` before initializing. When disabled, render empty attributes and skip initialization entirely.
177
178 165
Two constraints specific to this repository:
179 166
180
- **No inline scripts.** The CSP allows exactly one nonce-bearing script (the theme bootstrap). Bundling `posthog-js` through esbuild satisfies the policy; do not add the copy-paste snippet.
181
- **CSP `connect-src`.** `lib/openagents_web/plugs/content_security_policy.ex` currently allows only `'self' ws: wss:`. Extend `connect-src` with the ingest hosts `https://us.i.posthog.com` and `https://us-assets.i.posthog.com`, or autocapture batches will silently fail to send.
167
- **No inline scripts.** The CSP allows exactly one nonce-bearing script (the theme bootstrap). Bundling satisfies the policy; do not add the copy-paste snippet.
168
- **CSP `connect-src`.** The policy now allows `https://us.i.posthog.com` and `https://us-assets.i.posthog.com`; without them autocapture batches silently fail to send.
182 169
183
### Step 5: Capture pageviews, including LiveView navigation
170
### Step 5: Pageviews, including LiveView navigation (implemented)
184 171
185
`posthog-js` records full page loads automatically. LiveView navigations change the URL without a reload, so add the documented listener after initialization:
172
Automatic history tracking is disabled (`capture_pageview: false`) so exactly one producer owns each `$pageview`:
186 173
187
```javascript
188
window.addEventListener("phx:navigate", ({ detail: { href } }) => {
189
  if (window.posthog?.__loaded) {
190
    posthog.capture("$pageview", { $current_url: href })
191
  }
192
})
193
```
174
1. `app.js` captures one on initial load with the current URL.
175
2. A `phx:navigate` listener captures one per LiveView navigation with the target `href`.
194 176
195
Without this listener, every authenticated LiveView surface (chat, issues, projects, memory, computers) appears as one long session on the landing URL, which corrupts traffic and funnel analysis.
177
Without the listener, every authenticated LiveView surface (chat, issues, projects, memory, computers) appears as one long session on the landing URL, which corrupts traffic and funnel analysis. With both producers explicit, no navigation double counts.
196 178
197
### Step 6: Identify users with one canonical ID
179
### Step 6: Identification with one canonical ID (implemented)
198 180
199
Choose the canonical `distinct_id` now: `user_<id>` where `<id>` is the database user ID. Every producer, client and server, uses exactly this string.
181
The canonical distinct ID is `user_<uuid>` where `<uuid>` is the database user ID; `OpenAgents.Analytics.distinct_id/1` is the single derivation point.
200 182
201
Client side:
183
Server side:
202 184
203
1. Render the current user's `distinct_id` and stable profile properties (`github_login`, `github_id`) as data attributes in the root layout when a session exists.
204
2. After `posthog.init`, if a distinct ID attribute is present, call:
185
1. `AuthController.callback` distinguishes sign-up from sign-in by comparing the upserted row's `inserted_at` and `updated_at`: equality means the row was just created. It then captures `user_signed_up` or `user_signed_in`.
186
2. The same callback writes a `posthog_identity` session key (`distinct_id` plus `login`). `logout` reads it before dropping the session to capture `user_logged_out`.
187
3. `plug PostHog.Integrations.Plug` sits immediately before the router in the endpoint, attaching request metadata and reading tracing headers.
205 188
206
```javascript
207
posthog.identify(distinctId, {
208
  github_login: login,
209
})
210
```
189
Client side:
211 190
191
1. The root layout renders the session identity as data attributes through `PostHogBootstrap` — no database query on the render path.
192
2. After initialization, if a distinct ID attribute is present, `app.js` calls `posthog.identify(distinctId, { github_login })` once per browser session (guarded in `sessionStorage`). Identifying merges the anonymous pre-login events into the account's person.
212 193
Calling `identify` merges the anonymous ID that autocapture assigned before login into the identified person, so pre-authentication pageviews connect to the account. Guard the call so it fires once per browser session.
213 194
214 195
Server side:

@@ -218,11 +199,11 @@ Server side:

218 199
3. For requests, add `plug PostHog.Integrations.Plug` immediately before `plug OpenAgentsWeb.Router` in the endpoint. It attaches `$current_url`, method, and user-agent metadata to events captured during the request, and reads `X-PostHog-Distinct-Id` and `X-PostHog-Session-Id` headers when present.
219 200
4. Configure `posthog-js` tracing headers so browser fetches to this app carry those headers, linking server-side events to the originating browser session. Tracing headers are client-controlled analytics hints, never authorization input; server-side code must derive identity from the session, not from these headers.
220 201
221
For background processes (deployment workers, delegated work) there is no browser session: pass the owning `user_<id>` explicitly, or use a system distinct ID such as `system_<worker>` for unowned operational events.
202
For background processes (deployment workers, forge pushes) there is no browser session: events attribute to the owning account's `user_<uuid>` when one is known, or a stable system person such as `system_forge` for unowned operational events.
222 203
223
### Step 7: Instrument the event taxonomy
204
### Step 7: The event taxonomy (implemented)
224 205
225
Naming convention: snake_case, past tense, `object_verb` (`issue_created`, `chat_message_sent`). Every event gets the standard properties from step 3 plus the listed ones. Instrument domain contexts rather than individual controllers where one context serves both the LiveView UI and the GitHub-compatible JSON API, so both surfaces produce identical events.
206
Naming convention: snake_case, past tense, `object_verb` (`issue_created`, `chat_message_sent`). Every event gets the standard properties from step 3 plus the listed ones. Domain contexts rather than individual controllers carry the instrumentation wherever one context serves both the LiveView UI and the GitHub-compatible JSON API, so both surfaces produce identical events. Contexts that lacked an actor parameter gained an optional trailing actor argument (defaulting to `nil`, attributed to the surface's system person), and their call sites now pass the signed-in user.
226 207
227 208
Public and authentication:
228 209

@@ -234,39 +215,42 @@ Public and authentication:

234 215
| `user_signed_in` | `AuthController.callback` on returning user | `github_login` |
235 216
| `user_logged_out` | `AuthController.logout` | none |
236 217
218
Anonymous funnel events (`auth_started`, `auth_failed`) attribute to the browser tracing header when present, else `"anonymous"`.
219
237 220
Chat and delegated work:
238 221
239 222
| Event | Where | Properties |
240 223
| --- | --- | --- |
241
| `chat_opened` | `ChatLive.mount` | none |
242
| `chat_message_sent` | turn submission handler | `length_bucket`, `tools_available` |
243
| `chat_turn_completed` | turn completion (server) | `duration_ms`, `model`, `tool_count`, `outcome` |
244
| `memory_saved` | memory write path | `kind` |
245
| `memory_viewed` | `MemoryLive.mount` | none |
246
| `delegated_work_created` | delegated-work creation path | `objective_kind` |
247
| `delegated_work_completed` | worker terminal result | `outcome`, `duration_ms` |
248
| `computer_paired` | pairing approval | none |
249
| `agent_job_created` | `ComputerAgentJobsController.create` | none |
250
| `voice_call_started` / `voice_call_completed` | voice call lifecycle | `duration_ms` on completion |
224
| `chat_opened` | `ChatLive.mount` on connected mount | none |
225
| `chat_message_sent` | `ChatLive.launch_turn` | `length_bucket` |
226
| `chat_turn_completed` | terminal `turn_updated` broadcast in `ChatLive` | `outcome`: `completed`, `failed`, `cancelled`; `duration_ms` from turn timestamps |
227
| `memory_saved` | `ProfileMemory.remember_explicit` | `disposition`: `stored`, `already_active` |
228
| `memory_viewed` | `MemoryLive.mount` on connected mount | none |
229
| `computer_paired` | `ComputersController.approve_pairing` success | `tier` |
230
| `agent_job_created` | `ComputerAgentJobsController.create` success | `machine_tier` |
231
| `voice_call_started` / `voice_call_ended` | `VoiceCallController` create/delete success | `duration_ms` on end |
251 232
252 233
Issues, projects, and forge:
253 234
254 235
| Event | Where | Properties |
255 236
| --- | --- | --- |
256
| `issue_created` | issue creation context | `owner`, `repo`, `has_labels`, `has_assignees` |
257
| `issue_updated` | issue update context | `fields_changed` count |
258
| `issue_commented` | comment creation context | none |
237
| `issue_created` | `Issues.create_issue` | `owner`, `repo`, `has_labels`, `has_assignees` |
238
| `issue_updated` | `Issues.update_issue` | `owner`, `repo`, `state` |
239
| `issue_commented` | `Issues.create_comment` | `issue_number` |
259 240
| `label_created`, `milestone_created`, `project_created` | respective contexts | `owner`, `repo` |
260
| `project_item_added` | project item creation | none |
261
| `git_push_received` | push receipt path | `repo`, `commits_bucket` |
262
| `release_promoted` | promotion target path | `repo` |
263
| `deployment_started` / `deployment_completed` | deployment coordinator | `outcome`, `duration_ms` on completion |
241
| `project_item_added` | `Projects.create_project_item` | `project_number`, `has_issue` |
242
| `git_push_received` | `Forge.Pushes` live push path | `repo`, `refs_changed`, `duration_ms` |
243
| `release_promoted` | `Forge.Targets.promote` | `repo` |
244
| `deployment_started` / `deployment_completed` | `Forge.Deployment.run` | `repo`, `deployment_id`; completion adds `outcome`, `duration_ms` |
264 245
265
Deliberate omissions:
246
Scoping decisions worth remembering:
266 247
267
- Admin surfaces stay uninstrumented. They are operator-only, low volume, and would add noise to activation funnels.
268
- API read endpoints (`GET`) stay uninstrumented except where they represent product activation. Volume without signal costs money per event.
269
- Never capture message bodies, objective text, token material, ciphertexts, or raw query strings. The `$current_url` property contains query parameters; rely on the wrapper's redaction and keep sensitive routes out of custom properties.
248
- Forge pushes and deployments attribute to `system_forge`. Push capture sits only on the live receive-pack path; crash-recovery receipt reconciliation never captures, so recovered rows cannot double count.
249
- Delegated work started inside a conversation turn is represented by the chat turn events; the computers API path is covered by `agent_job_created`. A separate `delegated_work_created` would double count one of those surfaces.
250
- Memory owners without a linked account attribute to a synthetic `visitor_<id>` person.
251
- Admin surfaces stay uninstrumented: operator-only, low volume.
252
- API read endpoints (`GET`) stay uninstrumented except where they represent product activation.
253
- Never capture message bodies, objective text, token material, ciphertexts, or raw query strings. `$current_url` contains query parameters; rely on the wrapper's redaction denylist and keep sensitive routes out of custom properties.
270 254
271 255
### Step 8: Build the starter dashboards through the PostHog MCP
272 256

@@ -314,10 +298,10 @@ Work through this checklist in staging. Browser-side checks stay manual; every s

314 298
315 299
### Rollout order
316 300
317
1. Steps 1-4: foundation (config, SDKs, wrapper, client init). No taxonomy yet.
318
2. Step 5-6: pageviews and identification. Verify the auth funnel end to end.
319
3. Step 7: taxonomy, starting with authentication and chat, then issues and forge.
320
4. Step 8: dashboards.
301
1. Steps 1-6 are implemented and covered by the test suite; they activate the moment a project token is configured.
302
2. Set `OPENAGENTS_POSTHOG_PROJECT_TOKEN` in staging and confirm boot with capture live.
303
3. Step 7 events flow automatically; watch volume in the first days.
304
4. Step 8: build dashboards through the MCP.
321 305
5. Step 9 gates each stage; do not stack stages without verification.
322 306
323 307
## References
lib/openagents/analytics.ex added +229

@@ -0,0 +1,229 @@

1
defmodule OpenAgents.Analytics do
2
  @moduledoc """
3
  The single capture boundary for product analytics events.
4
5
  Every server-side PostHog capture goes through `capture/3`. The boundary
6
  enforces policy in one place:
7
8
  - Capture is a no-op unless `OPENAGENTS_POSTHOG_PROJECT_TOKEN` configured a
9
    project token at boot.
10
  - Standard properties (`environment`, `app_revision`) merge onto every event.
11
  - Property keys on the sensitive denylist are dropped, oversized values are
12
    truncated, and non-scalar shapes are bounded before anything leaves.
13
  - A capture failure never fails its caller: it logs and returns `:ok`.
14
15
  See docs/2026-08-21-posthog-integration-runbook.md for the event taxonomy and
16
  the identity rules. Browser events identify with the same canonical
17
  `user_<id>` distinct ID that this module derives for accounts.
18
  """
19
20
  require Logger
21
22
  import Plug.Conn, only: [get_req_header: 2]
23
24
  @sensitive_keys ~w(
25
    access_token api_key apikey authorization cookie credential ciphertext
26
    password refresh_token secret secret_key_base sdp state token verifier
27
  )
28
  @maximum_properties 40
29
  @maximum_list_items 20
30
  @maximum_map_entries 20
31
  @maximum_depth 3
32
  @maximum_value_bytes 512
33
34
  @type event_name :: String.t()
35
  @type distinct_id :: String.t()
36
37
  @doc """
38
  Whether capture is configured. False in development without a token, in the
39
  test environment, and wherever the project token was never set at boot.
40
  """
41
  @spec enabled?() :: boolean()
42
  def enabled? do
43
    case Application.get_env(:openagents, :posthog_project_token) do
44
      token when is_binary(token) -> String.trim(token) != ""
45
      _other -> false
46
    end
47
  end
48
49
  @doc """
50
  The canonical PostHog distinct ID for an account: `user_<database id>`.
51
52
  Account IDs are UUID strings. Browser `posthog.identify` calls must pass
53
  exactly this value so client and server events attach to one person.
54
  Already-prefixed values pass through unchanged, so the function is safe to
55
  call with any stored identifier.
56
  """
57
  @spec distinct_id(OpenAgents.Accounts.User.t() | term()) :: distinct_id()
58
  def distinct_id(%{id: id}) when is_binary(id), do: "user_#{id}"
59
60
  def distinct_id(id) when is_binary(id) do
61
    if String.starts_with?(id, ["user_", "system_", "visitor_", "anonymous"]) do
62
      id
63
    else
64
      "user_#{id}"
65
    end
66
  end
67
68
  def distinct_id(id) when is_integer(id), do: "user_#{id}"
69
70
  @doc """
71
  A synthetic distinct ID for events no account owns (forge pushes,
72
  deployments). Each surface gets its own stable person so operational volume
73
  stays separable from human behavior.
74
  """
75
  @spec system_distinct_id(String.t()) :: distinct_id()
76
  def system_distinct_id(surface) when is_binary(surface), do: "system_#{surface}"
77
78
  @doc """
79
  The browser's anonymous distinct ID when tracing headers are present, or
80
  `"anonymous"` otherwise. Tracing headers are client-controlled analytics
81
  hints; never use them for authorization decisions.
82
  """
83
  @spec browser_distinct_id(Plug.Conn.t()) :: distinct_id()
84
  def browser_distinct_id(conn) do
85
    case get_req_header(conn, "x-posthog-distinct-id") do
86
      [value | _] when byte_size(value) in 1..255 -> value
87
      _other -> "anonymous"
88
    end
89
  end
90
91
  @doc """
92
  Capture one product event. Returns `:ok` unconditionally; failures log and
93
  swallow so instrumentation can never break a request or a turn.
94
  """
95
  @spec capture(event_name(), distinct_id(), map()) :: :ok
96
  def capture(event, distinct_id, properties \\ %{})
97
98
  def capture(event, distinct_id, properties)
99
      when is_binary(event) and is_binary(distinct_id) and is_map(properties) do
100
    if enabled?() do
101
      dispatch(event, distinct_id, standard_properties(properties))
102
    else
103
      :ok
104
    end
105
  end
106
107
  def capture(_event, _distinct_id, _properties), do: :ok
108
109
  defp dispatch(event, distinct_id, properties) do
110
    sink().capture(event, distinct_id, properties)
111
    :ok
112
  rescue
113
    error ->
114
      Logger.warning(
115
        "analytics_capture_failed event=#{event} code=#{OpenAgents.OperationalLog.code(error)}"
116
      )
117
118
      :ok
119
  catch
120
    _kind, _reason ->
121
      Logger.warning("analytics_capture_failed event=#{event} code=analytics_capture_crashed")
122
      :ok
123
  end
124
125
  defp sink do
126
    Application.get_env(:openagents, :analytics_sink, OpenAgents.Analytics.PostHogSink)
127
  end
128
129
  defp standard_properties(properties) do
130
    surface = Map.get(properties, "surface") || Map.get(properties, :surface) || "server"
131
132
    %{"environment" => environment(), "app_revision" => revision(), "surface" => surface}
133
    |> Map.merge(sanitize(properties))
134
  end
135
136
  defp environment do
137
    case Application.get_env(:openagents, :runtime_environment) do
138
      value when is_atom(value) -> Atom.to_string(value)
139
      _other -> "unknown"
140
    end
141
  end
142
143
  defp revision, do: OpenAgents.BuildInfo.revision()
144
145
  # ── property sanitization ────────────────────────────────────────────────
146
147
  defp sanitize(properties) when is_map(properties) do
148
    properties
149
    |> Enum.take(@maximum_properties)
150
    |> Enum.reduce(%{}, fn {key, value}, acc ->
151
      with {:ok, string_key} <- sanitize_key(key),
152
           {:ok, sanitized} <- sanitize_value(value, 1) do
153
        Map.put(acc, string_key, sanitized)
154
      else
155
        :drop -> acc
156
      end
157
    end)
158
  end
159
160
  defp sanitize_key(key) when is_binary(key) do
161
    normalized = String.downcase(key)
162
163
    if normalized in @sensitive_keys or sensitive_suffix?(normalized) do
164
      :drop
165
    else
166
      {:ok, key}
167
    end
168
  end
169
170
  defp sanitize_key(key) when is_atom(key), do: sanitize_key(Atom.to_string(key))
171
  defp sanitize_key(_key), do: :drop
172
173
  defp sensitive_suffix?(key) do
174
    String.ends_with?(key, ["_token", "_secret", "_password", "_credential", "_ciphertext"])
175
  end
176
177
  defp sanitize_value(nil, _depth), do: {:ok, nil}
178
  defp sanitize_value(value, _depth) when is_boolean(value), do: {:ok, value}
179
  defp sanitize_value(value, _depth) when is_integer(value), do: {:ok, value}
180
  defp sanitize_value(value, _depth) when is_float(value), do: {:ok, value}
181
182
  defp sanitize_value(value, _depth) when is_binary(value) do
183
    if byte_size(value) > @maximum_value_bytes do
184
      {:ok, "[truncated]"}
185
    else
186
      {:ok, value}
187
    end
188
  end
189
190
  defp sanitize_value(value, depth) when is_atom(value) and depth <= @maximum_depth,
191
    do: {:ok, Atom.to_string(value)}
192
193
  defp sanitize_value(value, depth) when is_list(value) and depth < @maximum_depth do
194
    items =
195
      value
196
      |> Enum.take(@maximum_list_items)
197
      |> Enum.reduce_while([], fn item, acc ->
198
        case sanitize_value(item, depth + 1) do
199
          {:ok, sanitized} -> {:cont, [sanitized | acc]}
200
          :drop -> {:cont, acc}
201
        end
202
      end)
203
      |> Enum.reverse()
204
205
    {:ok, items}
206
  end
207
208
  defp sanitize_value(value, depth) when is_map(value) and depth < @maximum_depth do
209
    if is_struct(value) do
210
      :drop
211
    else
212
      entries =
213
        value
214
        |> Enum.take(@maximum_map_entries)
215
        |> Enum.reduce(%{}, fn {key, inner}, acc ->
216
          with {:ok, string_key} <- sanitize_key(key),
217
               {:ok, sanitized} <- sanitize_value(inner, depth + 1) do
218
            Map.put(acc, string_key, sanitized)
219
          else
220
            :drop -> acc
221
          end
222
        end)
223
224
      {:ok, entries}
225
    end
226
  end
227
228
  defp sanitize_value(_value, _depth), do: :drop
229
end
lib/openagents/analytics/posthog_sink.ex added +15

@@ -0,0 +1,15 @@

1
defmodule OpenAgents.Analytics.PostHogSink do
2
  @moduledoc """
3
  The production analytics sink: the `posthog` Hex package.
4
5
  The package batches events and flushes them from its own supervision tree,
6
  which `OpenAgents.Application` starts only when a project token is
7
  configured. Events carry the distinct ID as a property, per the package API.
8
  """
9
10
  @spec capture(OpenAgents.Analytics.event_name(), OpenAgents.Analytics.distinct_id(), map()) ::
11
          term()
12
  def capture(event, distinct_id, properties) do
13
    PostHog.capture(event, Map.put(properties, :distinct_id, distinct_id))
14
  end
15
end
lib/openagents/application.ex modified +36 -16

@@ -41,22 +41,19 @@ defmodule OpenAgents.Application do

41 41
    tool_snapshot = OpenAgents.Tools.Registry.install!(tool_modules)
42 42
    :ok = OpenAgents.RuntimeConfig.verify_startup!(runtime_config, tool_snapshot)
43 43
44
    children = [
45
      OpenAgentsWeb.Telemetry,
46
      OpenAgents.Repo,
47
      OpenAgents.ReleaseState,
48
      # Deployment identity and boot convergence must settle before cluster
49
      # discovery or the endpoint can make this node externally reachable.
50
      OpenAgents.Forge.DeploymentNode,
51
      OpenAgents.Forge.BootConverge,
52
      {DNSCluster, query: Application.get_env(:openagents, :dns_cluster_query) || :ignore},
53
      {Phoenix.PubSub, name: OpenAgents.PubSub},
54
      OpenAgents.RuntimeSupervisor,
55
      # Start a worker by calling: OpenAgents.Worker.start_link(arg)
56
      # {OpenAgents.Worker, arg},
57
      # Start to serve requests, typically the last entry
58
      OpenAgentsWeb.Endpoint
59
    ]
44
    children =
45
      [
46
        OpenAgentsWeb.Telemetry,
47
        OpenAgents.Repo,
48
        OpenAgents.ReleaseState,
49
        # Deployment identity and boot convergence must settle before cluster
50
        # discovery or the endpoint can make this node externally reachable.
51
        OpenAgents.Forge.DeploymentNode,
52
        OpenAgents.Forge.BootConverge,
53
        {DNSCluster, query: Application.get_env(:openagents, :dns_cluster_query) || :ignore},
54
        {Phoenix.PubSub, name: OpenAgents.PubSub},
55
        OpenAgents.RuntimeSupervisor
56
      ] ++ analytics_children() ++ [OpenAgentsWeb.Endpoint]
60 57
61 58
    # See https://elixir.hexdocs.pm/Supervisor.html
62 59
    # for other strategies and supported options

@@ -91,6 +88,29 @@ defmodule OpenAgents.Application do

91 88
    Supervisor.start_link([child], strategy: :one_for_one, name: OpenAgents.SCV.Supervisor)
92 89
  end
93 90
91
  # The analytics supervision tree starts only when a project token was
92
  # configured at boot; without one, OpenAgents.Analytics is a no-op and no
93
  # PostHog process exists. See docs/2026-08-21-posthog-integration-runbook.md.
94
  defp analytics_children do
95
    case posthog_config() do
96
      nil -> []
97
      config -> [{PostHog.Supervisor, config}]
98
    end
99
  end
100
101
  defp posthog_config do
102
    case Application.get_env(:posthog, :api_key) do
103
      key when is_binary(key) and key != "" ->
104
        :posthog
105
        |> Application.get_env([])
106
        |> Keyword.put(:enable, false)
107
        |> PostHog.Config.validate!()
108
109
      _missing ->
110
        nil
111
    end
112
  end
113
94 114
  defp run_scv_worker do
95 115
    exit_status =
96 116
      try do
lib/openagents/forge/deployment.ex modified +17

@@ -9,12 +9,22 @@ defmodule OpenAgents.Forge.Deployment do

9 9
  exact rollback on every participant that issued a token.
10 10
  """
11 11
12
  alias OpenAgents.Analytics
12 13
  alias OpenAgents.Forge.BuildArtifact
13 14
  alias OpenAgents.Forge.BuildProtocol
14 15
  alias OpenAgents.Forge.DeploymentNode
15 16
16 17
  @default_timeout_ms 15_000
17 18
19
  defp capture_deployment_completed(base, outcome, started_at) do
20
    Analytics.capture("deployment_completed", Analytics.system_distinct_id("forge"), %{
21
      "repo" => base.repo,
22
      "deployment_id" => base.deployment_id,
23
      "outcome" => outcome,
24
      "duration_ms" => DateTime.diff(DateTime.utc_now(), started_at, :millisecond)
25
    })
26
  end
27
18 28
  @doc "Run through fleet commit, retaining tokens until `finalize/1`."
19 29
  def run(build, verified, artifact_bytes, opts \\ []) do
20 30
    deployment_id = Ecto.UUID.generate()

@@ -36,6 +46,11 @@ defmodule OpenAgents.Forge.Deployment do

36 46
      node_results: %{}
37 47
    }
38 48
49
    Analytics.capture("deployment_started", Analytics.system_distinct_id("forge"), %{
50
      "repo" => base.repo,
51
      "deployment_id" => deployment_id
52
    })
53
39 54
    result =
40 55
      with {:ok, session} <- snapshot_fleet(base, opts),
41 56
           {:ok, session} <- prepare_fleet(session, artifact_bytes, opts),

@@ -54,9 +69,11 @@ defmodule OpenAgents.Forge.Deployment do

54 69
55 70
    case result do
56 71
      {:ok, session} ->
72
        capture_deployment_completed(base, "committed", base.started_at)
57 73
        {:ok, public_session(session)}
58 74
59 75
      {:error, reason, session} ->
76
        capture_deployment_completed(base, "rolled_back", base.started_at)
60 77
        rollback_failure(session, reason, opts)
61 78
    end
62 79
  end
lib/openagents/forge/pushes.ex modified +22 -1

@@ -20,6 +20,7 @@ defmodule OpenAgents.Forge.Pushes do

20 20
21 21
  require Logger
22 22
23
  alias OpenAgents.Analytics
23 24
  alias OpenAgents.Forge.{GitHTTP, PushReceipt, Repos, Sync, WAL}
24 25
  alias OpenAgents.Repo
25 26

@@ -56,7 +57,15 @@ defmodule OpenAgents.Forge.Pushes do

56 57
        case persist(repo, body, refs_after, principal) do
57 58
          {:ok, seq} ->
58 59
            Repos.record_applied_seq!(repo, seq)
59
            record_receipt(repo, seq, refs_before, refs_after, principal, started_at)
60
61
            capture_push_received(
62
              repo,
63
              record_receipt(repo, seq, refs_before, refs_after, principal, started_at),
64
              refs_before,
65
              refs_after,
66
              started_at
67
            )
68
60 69
            broadcast(repo, seq, refs_after)
61 70
            mirror_async(repo)
62 71
            {:ok, output}

@@ -76,6 +85,18 @@ defmodule OpenAgents.Forge.Pushes do

76 85
    end
77 86
  end
78 87
88
  # Live-push analytics only. Crash-recovery reconciliation reuses
89
  # `record_receipt/5` without capturing, so recovered rows never double count.
90
  defp capture_push_received(repo, {:ok, _receipt}, refs_before, refs_after, started_at) do
91
    Analytics.capture("git_push_received", Analytics.system_distinct_id("forge"), %{
92
      "repo" => repo,
93
      "refs_changed" => Enum.count(refs_after, fn {name, sha} -> refs_before[name] != sha end),
94
      "duration_ms" => System.monotonic_time(:millisecond) - started_at
95
    })
96
  end
97
98
  defp capture_push_received(_repo, :error, _before, _after, _started_at), do: :ok
99
79 100
  # ── WAL persist (ack barrier) ───────────────────────────────────────────
80 101
81 102
  defp persist(repo, body, refs_after, principal) do
lib/openagents/forge/targets.ex modified +5

@@ -15,6 +15,7 @@ defmodule OpenAgents.Forge.Targets do

15 15
16 16
  import Ecto.Query
17 17
18
  alias OpenAgents.Analytics
18 19
  alias OpenAgents.Forge.BuildReceipt
19 20
  alias OpenAgents.Forge.DeployReceipt
20 21
  alias OpenAgents.Forge.Target

@@ -58,6 +59,10 @@ defmodule OpenAgents.Forge.Targets do

58 59
      |> Repo.insert()
59 60
      |> case do
60 61
        {:ok, target} ->
62
          Analytics.capture("release_promoted", Analytics.system_distinct_id("forge"), %{
63
            "repo" => repo
64
          })
65
61 66
          broadcast_promotion(target)
62 67
          {:ok, target}
63 68
lib/openagents/issues.ex modified +57 -1

@@ -4,6 +4,7 @@ defmodule OpenAgents.Issues do

4 4
  import Ecto.Query, warn: false
5 5
6 6
  alias OpenAgents.Accounts.User
7
  alias OpenAgents.Analytics
7 8
  alias OpenAgents.Issues.{Comment, Issue}
8 9
  alias OpenAgents.Labels
9 10
  alias OpenAgents.Labels.Label

@@ -89,12 +90,25 @@ defmodule OpenAgents.Issues do

89 90
          {:error, changeset}
90 91
        end
91 92
93
      {:ok, issue} ->
94
        Analytics.capture("issue_created", issue_distinct_id(normalized), %{
95
          "owner" => repository.owner,
96
          "repo" => repository.name,
97
          "has_labels" => has_labels?(normalized),
98
          "has_assignees" => has_assignees?(normalized)
99
        })
100
101
        {:ok, issue}
102
92 103
      result ->
93 104
        result
94 105
    end
95 106
  end
96 107
97
  def update_issue(%Issue{} = issue, attrs) do
108
  def update_issue(issue, attrs, actor \\ nil)
109
110
  def update_issue(%Issue{} = issue, attrs, actor)
111
      when is_nil(actor) or is_struct(actor, User) do
98 112
    repository = %Repository{id: issue.repository_id}
99 113
100 114
    Repo.transaction(fn ->

@@ -113,6 +127,21 @@ defmodule OpenAgents.Issues do

113 127
        {:error, changeset} -> Repo.rollback(changeset)
114 128
      end
115 129
    end)
130
    |> case do
131
      {:ok, updated} ->
132
        repository = Repo.get(Repository, issue.repository_id)
133
134
        Analytics.capture("issue_updated", actor_distinct_id(actor), %{
135
          "owner" => repository && repository.owner,
136
          "repo" => repository && repository.name,
137
          "state" => updated.state
138
        })
139
140
        {:ok, updated}
141
142
      result ->
143
        result
144
    end
116 145
  end
117 146
118 147
  def change_issue(%Issue{} = issue, attrs \\ %{}) do

@@ -259,6 +288,17 @@ defmodule OpenAgents.Issues do

259 288
        {_, _} -> Repo.rollback(%Comment{})
260 289
      end
261 290
    end)
291
    |> case do
292
      {:ok, comment} ->
293
        Analytics.capture("issue_commented", issue_distinct_id(normalized), %{
294
          "issue_number" => issue.number
295
        })
296
297
        {:ok, comment}
298
299
      result ->
300
        result
301
    end
262 302
  end
263 303
264 304
  def update_comment(%Comment{} = comment, attrs) do

@@ -408,6 +448,22 @@ defmodule OpenAgents.Issues do

408 448
    |> Map.put("user", user_json(author))
409 449
  end
410 450
451
  defp issue_distinct_id(%{"author_user_id" => author_id}) when is_integer(author_id),
452
    do: Analytics.distinct_id(author_id)
453
454
  defp issue_distinct_id(_attrs), do: Analytics.system_distinct_id("api")
455
456
  defp actor_distinct_id(nil), do: Analytics.system_distinct_id("api")
457
  defp actor_distinct_id(%User{} = actor), do: Analytics.distinct_id(actor)
458
459
  defp has_labels?(%{"labels" => labels}) when is_list(labels), do: labels != []
460
  defp has_labels?(_attrs), do: false
461
462
  defp has_assignees?(%{"assignees" => assignees}) when is_list(assignees),
463
    do: assignees != []
464
465
  defp has_assignees?(_attrs), do: false
466
411 467
  defp dump_repository_ids(rows) do
412 468
    Enum.map(rows, fn row ->
413 469
      row = Map.update!(row, :repository_id, &Ecto.UUID.dump!/1)
lib/openagents/labels.ex modified +20 -2

@@ -4,6 +4,8 @@ defmodule OpenAgents.Labels do

4 4
  """
5 5
6 6
  import Ecto.Query, warn: false
7
  alias OpenAgents.Accounts.User
8
  alias OpenAgents.Analytics
7 9
  alias OpenAgents.Repo
8 10
  alias OpenAgents.Repositories
9 11
  alias OpenAgents.Repositories.Repository

@@ -80,9 +82,10 @@ defmodule OpenAgents.Labels do

80 82
      {:error, %Ecto.Changeset{}}
81 83
82 84
  """
83
  def create_label(attrs), do: create_label(Repositories.initial_repository!(), attrs)
85
  def create_label(attrs), do: create_label(Repositories.initial_repository!(), attrs, nil)
84 86
85
  def create_label(%Repository{} = repository, attrs) do
87
  def create_label(%Repository{} = repository, attrs, actor \\ nil)
88
      when is_nil(actor) or is_struct(actor, User) do
86 89
    attrs =
87 90
      attrs
88 91
      |> Enum.into(%{}, fn {key, value} -> {to_string(key), value} end)

@@ -91,8 +94,23 @@ defmodule OpenAgents.Labels do

91 94
    %Label{}
92 95
    |> Label.changeset(attrs)
93 96
    |> Repo.insert()
97
    |> case do
98
      {:ok, label} ->
99
        Analytics.capture("label_created", actor_distinct_id(actor), %{
100
          "owner" => repository.owner,
101
          "repo" => repository.name
102
        })
103
104
        {:ok, label}
105
106
      result ->
107
        result
108
    end
94 109
  end
95 110
111
  defp actor_distinct_id(nil), do: Analytics.system_distinct_id("api")
112
  defp actor_distinct_id(%User{} = actor), do: Analytics.distinct_id(actor)
113
96 114
  @doc """
97 115
  Updates a label.
98 116
lib/openagents/milestones.ex modified +26 -4

@@ -4,6 +4,8 @@ defmodule OpenAgents.Milestones do

4 4
  """
5 5
6 6
  import Ecto.Query, warn: false
7
  alias OpenAgents.Accounts.User
8
  alias OpenAgents.Analytics
7 9
  alias OpenAgents.Repo
8 10
  alias OpenAgents.Repositories
9 11
  alias OpenAgents.Repositories.Repository

@@ -79,15 +81,23 @@ defmodule OpenAgents.Milestones do

79 81
80 82
  """
81 83
  def create_milestone(attrs \\ %{}),
82
    do: create_milestone(Repositories.initial_repository!(), attrs)
84
    do: create_milestone(Repositories.initial_repository!(), attrs, nil)
83 85
84
  def create_milestone(%Repository{} = repository, attrs) do
86
  def create_milestone(%Repository{} = repository, attrs, actor \\ nil)
87
      when is_nil(actor) or is_struct(actor, User) do
85 88
    normalized = for {k, v} <- attrs, into: %{}, do: {to_string(k), v}
86 89
    explicit_number? = Map.has_key?(normalized, "number")
87
    create_milestone_with_number(repository, normalized, explicit_number?, 20)
90
91
    create_milestone_with_number(repository, normalized, explicit_number?, actor, 20)
88 92
  end
89 93
90
  defp create_milestone_with_number(repository, normalized, explicit_number?, attempts_remaining) do
94
  defp create_milestone_with_number(
95
         repository,
96
         normalized,
97
         explicit_number?,
98
         actor,
99
         attempts_remaining
100
       ) do
91 101
    number = next_milestone_number(repository.id)
92 102
93 103
    %Milestone{}

@@ -104,17 +114,29 @@ defmodule OpenAgents.Milestones do

104 114
            repository,
105 115
            normalized,
106 116
            explicit_number?,
117
            actor,
107 118
            attempts_remaining - 1
108 119
          )
109 120
        else
110 121
          {:error, changeset}
111 122
        end
112 123
124
      {:ok, milestone} ->
125
        Analytics.capture("milestone_created", actor_distinct_id(actor), %{
126
          "owner" => repository.owner,
127
          "repo" => repository.name
128
        })
129
130
        {:ok, milestone}
131
113 132
      result ->
114 133
        result
115 134
    end
116 135
  end
117 136
137
  defp actor_distinct_id(nil), do: Analytics.system_distinct_id("api")
138
  defp actor_distinct_id(%User{} = actor), do: Analytics.distinct_id(actor)
139
118 140
  defp next_milestone_number(repository_id) do
119 141
    case Repo.aggregate(
120 142
           from(m in Milestone, where: m.repository_id == ^repository_id),
lib/openagents/profile_memory.ex modified +18

@@ -9,6 +9,7 @@ defmodule OpenAgents.ProfileMemory do

9 9
10 10
  import Ecto.Query
11 11
12
  alias OpenAgents.Analytics
12 13
  alias OpenAgents.Conversations.{Message, Visitor}
13 14
  alias OpenAgents.Memory.{Policy, Redaction}
14 15
  alias OpenAgents.ProfileMemory.{Record, Scope, Snapshot, SnapshotRecord, Source}

@@ -69,6 +70,16 @@ defmodule OpenAgents.ProfileMemory do

69 70
        end)
70 71
      end
71 72
73
    case result do
74
      {:ok, %{disposition: disposition}} ->
75
        Analytics.capture("memory_saved", owner_distinct_id(owner), %{
76
          "disposition" => disposition
77
        })
78
79
      _other ->
80
        :ok
81
    end
82
72 83
    broadcast_result(owner, result)
73 84
  end
74 85

@@ -955,4 +966,11 @@ defmodule OpenAgents.ProfileMemory do

955 966
  end
956 967
957 968
  defp broadcast_result(_owner, error), do: error
969
970
  # Memory owners are account-scoped visitors; an owner without a user row is
971
  # a browser-scoped visitor and gets its own synthetic person.
972
  defp owner_distinct_id(%Visitor{user_id: user_id}) when is_integer(user_id),
973
    do: Analytics.distinct_id(user_id)
974
975
  defp owner_distinct_id(%Visitor{id: id}), do: "visitor_#{id}"
958 976
end
lib/openagents/projects.ex modified +71 -28

@@ -4,6 +4,7 @@ defmodule OpenAgents.Projects do

4 4
  import Ecto.Query, warn: false
5 5
6 6
  alias OpenAgents.Accounts.User
7
  alias OpenAgents.Analytics
7 8
  alias OpenAgents.Issues.Issue
8 9
  alias OpenAgents.ProjectFields.ProjectField
9 10
  alias OpenAgents.ProjectItems.ProjectItem

@@ -85,10 +86,16 @@ defmodule OpenAgents.Projects do

85 86
      |> Map.put("repository_id", repository.id)
86 87
      |> put_owner(owner_user)
87 88
88
    create_project_with_number(repository, normalized, explicit_number?, 20)
89
    create_project_with_number(repository, normalized, explicit_number?, owner_user, 20)
89 90
  end
90 91
91
  defp create_project_with_number(repository, normalized, explicit_number?, attempts_remaining) do
92
  defp create_project_with_number(
93
         repository,
94
         normalized,
95
         explicit_number?,
96
         owner_user,
97
         attempts_remaining
98
       ) do
92 99
    normalized = Map.put_new(normalized, "number", next_project_number(repository.id))
93 100
94 101
    %Project{}

@@ -98,16 +105,34 @@ defmodule OpenAgents.Projects do

98 105
      {:error, changeset} when not explicit_number? and attempts_remaining > 1 ->
99 106
        if number_conflict?(changeset) do
100 107
          normalized = Map.delete(normalized, "number")
101
          create_project_with_number(repository, normalized, false, attempts_remaining - 1)
108
109
          create_project_with_number(
110
            repository,
111
            normalized,
112
            false,
113
            owner_user,
114
            attempts_remaining - 1
115
          )
102 116
        else
103 117
          {:error, changeset}
104 118
        end
105 119
120
      {:ok, project} ->
121
        Analytics.capture("project_created", actor_distinct_id(owner_user), %{
122
          "owner" => repository.owner,
123
          "repo" => repository.name
124
        })
125
126
        {:ok, project}
127
106 128
      result ->
107 129
        result
108 130
    end
109 131
  end
110 132
133
  defp actor_distinct_id(nil), do: Analytics.system_distinct_id("api")
134
  defp actor_distinct_id(%User{} = actor), do: Analytics.distinct_id(actor)
135
111 136
  def update_project(%Project{} = project, attrs) do
112 137
    attrs = attrs |> to_string_map() |> Map.drop(["repository_id", "owner_user_id"])
113 138

@@ -178,40 +203,58 @@ defmodule OpenAgents.Projects do

178 203
    )
179 204
  end
180 205
181
  def create_project_item(attrs, project_id) when is_integer(project_id) do
206
  def create_project_item(attrs, project, actor \\ nil)
207
208
  def create_project_item(attrs, project_id, actor)
209
      when is_integer(project_id) do
182 210
    project = get_project!(project_id)
183
    create_project_item(attrs, project)
211
    create_project_item(attrs, project, actor)
184 212
  end
185 213
186
  def create_project_item(attrs, %Project{} = project) do
214
  def create_project_item(attrs, %Project{} = project, actor)
215
      when is_nil(actor) or is_struct(actor, User) do
187 216
    attrs = to_string_map(attrs)
188 217
    values = Map.get(attrs, "values", %{})
189 218
190
    case Map.get(attrs, "issue_number") do
191
      nil ->
192
        %ProjectItem{}
193
        |> ProjectItem.changeset(%{
194
          "project_id" => project.id,
195
          "repository_id" => project.repository_id,
196
          "values" => values
219
    result =
220
      case Map.get(attrs, "issue_number") do
221
        nil ->
222
          %ProjectItem{}
223
          |> ProjectItem.changeset(%{
224
            "project_id" => project.id,
225
            "repository_id" => project.repository_id,
226
            "values" => values
227
          })
228
          |> Ecto.Changeset.apply_action(:insert)
229
230
        issue_number ->
231
          issue =
232
            Repo.get_by!(Issue,
233
              repository_id: project.repository_id,
234
              number: issue_number
235
            )
236
237
          %ProjectItem{}
238
          |> ProjectItem.changeset(%{
239
            "project_id" => project.id,
240
            "issue_id" => issue.id,
241
            "repository_id" => project.repository_id,
242
            "values" => values
243
          })
244
          |> Repo.insert()
245
      end
246
247
    case result do
248
      {:ok, item} ->
249
        Analytics.capture("project_item_added", actor_distinct_id(actor), %{
250
          "project_number" => project.number,
251
          "has_issue" => match?(%ProjectItem{issue_id: id} when id != nil, item)
197 252
        })
198
        |> Ecto.Changeset.apply_action(:insert)
199 253
200
      issue_number ->
201
        issue =
202
          Repo.get_by!(Issue,
203
            repository_id: project.repository_id,
204
            number: issue_number
205
          )
254
        {:ok, item}
206 255
207
        %ProjectItem{}
208
        |> ProjectItem.changeset(%{
209
          "project_id" => project.id,
210
          "issue_id" => issue.id,
211
          "repository_id" => project.repository_id,
212
          "values" => values
213
        })
214
        |> Repo.insert()
256
      result ->
257
        result
215 258
    end
216 259
  end
217 260
lib/openagents_web/components/layouts.ex modified +19

@@ -681,6 +681,25 @@ defmodule OpenAgentsWeb.Layouts do

681 681
    end
682 682
  end
683 683
684
  @doc """
685
  One browser analytics identity field from the session-written map, or nil.
686
687
  The root layout renders these as data attributes for `app.js`; a missing or
688
  malformed identity renders nothing rather than raising.
689
  """
690
  def posthog_identity(assigns, key) when is_atom(key) do
691
    case assigns[:posthog_identity] do
692
      %{} = identity ->
693
        case identity[Atom.to_string(key)] do
694
          value when is_binary(value) and value != "" -> value
695
          _other -> nil
696
        end
697
698
      _absent ->
699
        nil
700
    end
701
  end
702
684 703
  defp account_display_name(%{github_name: name}) when is_binary(name) and name != "", do: name
685 704
  defp account_display_name(%{github_login: login}), do: "@" <> login
686 705
lib/openagents_web/components/layouts/root.html.heex modified +8 -1

@@ -89,7 +89,14 @@

89 89
    <script defer phx-track-static type="text/javascript" src={~p"/assets/js/app.js"}>
90 90
    </script>
91 91
  </head>
92
  <body class="h-screen overflow-hidden overscroll-none">
92
  <body
93
    class="h-screen overflow-hidden overscroll-none"
94
    data-posthog-enabled={Atom.to_string(assigns[:posthog_enabled] == true)}
95
    data-posthog-token={assigns[:posthog_token]}
96
    data-posthog-api-host={assigns[:posthog_api_host]}
97
    data-posthog-distinct-id={posthog_identity(assigns, :distinct_id)}
98
    data-posthog-login={posthog_identity(assigns, :login)}
99
  >
93 100
    {@inner_content}
94 101
  </body>
95 102
</html>
lib/openagents_web/controllers/auth_controller.ex modified +38 -7

@@ -1,13 +1,16 @@

1 1
defmodule OpenAgentsWeb.AuthController do
2 2
  use OpenAgentsWeb, :controller
3 3
4
  alias OpenAgents.{Accounts, GitHubOAuth, Repositories}
4
  alias OpenAgents.{Accounts, Analytics, GitHubOAuth, Repositories}
5 5
6 6
  @attempt_session_key "github_oauth_attempt"
7
  @identity_session_key "posthog_identity"
7 8
8 9
  def start(conn, %{"github_tools" => "enabled"}) do
9 10
    case GitHubOAuth.begin_authorization() do
10 11
      {:ok, attempt, authorization_url} ->
12
        Analytics.capture("auth_started", Analytics.browser_distinct_id(conn))
13
11 14
        conn
12 15
        |> put_resp_header("cache-control", "no-store")
13 16
        |> put_session(@attempt_session_key, attempt)

@@ -33,10 +36,13 @@ defmodule OpenAgentsWeb.AuthController do

33 36
         {:ok, _namespace} <- Repositories.ensure_user_namespace(active_user),
34 37
         {:ok, _stored} <-
35 38
           Accounts.store_github_token(active_user, access_token, granted_scopes) do
39
      capture_sign_in(active_user)
40
36 41
      conn
37 42
      |> clear_session()
38 43
      |> configure_session(renew: true)
39 44
      |> put_session("user_id", active_user.id)
45
      |> put_session(@identity_session_key, identity(active_user))
40 46
      |> put_resp_header("cache-control", "no-store")
41 47
      |> redirect(to: ~p"/chat")
42 48
    else

@@ -46,18 +52,24 @@ defmodule OpenAgentsWeb.AuthController do

46 52
  end
47 53
48 54
  def callback(conn, %{"error" => _provider_error}) do
49
    conn
50
    |> delete_session(@attempt_session_key)
51
    |> auth_failure("denied")
55
    conn = delete_session(conn, @attempt_session_key)
56
    auth_failure(conn, "denied")
52 57
  end
53 58
54 59
  def callback(conn, _params) do
55
    conn
56
    |> delete_session(@attempt_session_key)
57
    |> auth_failure("failed")
60
    conn = delete_session(conn, @attempt_session_key)
61
    auth_failure(conn, "failed")
58 62
  end
59 63
60 64
  def logout(conn, _params) do
65
    case get_session(conn, @identity_session_key) do
66
      %{"distinct_id" => distinct_id} when is_binary(distinct_id) ->
67
        Analytics.capture("user_logged_out", distinct_id)
68
69
      _absent ->
70
        :ok
71
    end
72
61 73
    conn
62 74
    |> clear_session()
63 75
    |> configure_session(drop: true)

@@ -81,7 +93,26 @@ defmodule OpenAgentsWeb.AuthController do

81 93
    end
82 94
  end
83 95
96
  # A row created and updated in the same write is a first sign-in; anything
97
  # else reauthenticated an existing account.
98
  defp capture_sign_in(user) do
99
    event =
100
      if DateTime.compare(user.inserted_at, user.updated_at) == :eq,
101
        do: "user_signed_up",
102
        else: "user_signed_in"
103
104
    Analytics.capture(event, Analytics.distinct_id(user), %{
105
      "github_login" => user.github_login
106
    })
107
  end
108
109
  defp identity(user) do
110
    %{"distinct_id" => Analytics.distinct_id(user), "login" => user.github_login}
111
  end
112
84 113
  defp auth_failure(conn, code) do
114
    Analytics.capture("auth_failed", Analytics.browser_distinct_id(conn), %{"reason" => code})
115
85 116
    conn
86 117
    |> clear_session()
87 118
    |> configure_session(renew: true)
lib/openagents_web/controllers/computer_agent_jobs_controller.ex modified +5

@@ -3,6 +3,7 @@ defmodule OpenAgentsWeb.ComputerAgentJobsController do

3 3
4 4
  use OpenAgentsWeb, :controller
5 5
6
  alias OpenAgents.Analytics
6 7
  alias OpenAgents.ComputerAgentJobs
7 8
  alias OpenAgents.Conversations
8 9
  alias OpenAgents.Machines

@@ -15,6 +16,10 @@ defmodule OpenAgentsWeb.ComputerAgentJobsController do

15 16
    with {:ok, machine} <- Machines.get_machine(user.id, machine_id),
16 17
         {:ok, conversation} <- Conversations.ensure_conversation(user),
17 18
         {:ok, job} <- ComputerAgentJobs.start(user, machine, conversation, params) do
19
      Analytics.capture("agent_job_created", Analytics.distinct_id(user), %{
20
        "machine_tier" => machine.tier
21
      })
22
18 23
      conn
19 24
      |> put_status(:accepted)
20 25
      |> json(%{"job" => job_projection(job)})
lib/openagents_web/controllers/computers_controller.ex modified +12 -2

@@ -3,6 +3,7 @@ defmodule OpenAgentsWeb.ComputersController do

3 3
4 4
  use OpenAgentsWeb, :controller
5 5
6
  alias OpenAgents.Analytics
6 7
  alias OpenAgents.Computer
7 8
  alias OpenAgents.Machines
8 9
  alias OpenAgents.Machines.Machine

@@ -23,8 +24,17 @@ defmodule OpenAgentsWeb.ComputersController do

23 24
  def approve_pairing(conn, %{"id" => pairing_id, "code" => code}) do
24 25
    if Computer.enabled?() do
25 26
      case Machines.approve_pairing(conn.assigns.current_user, pairing_id, code) do
26
        {:ok, machine} -> json(conn, %{"computer" => computer_projection(machine)})
27
        {:error, reason} -> pairing_error(conn, reason)
27
        {:ok, machine} ->
28
          Analytics.capture(
29
            "computer_paired",
30
            Analytics.distinct_id(conn.assigns.current_user),
31
            %{"tier" => machine.tier}
32
          )
33
34
          json(conn, %{"computer" => computer_projection(machine)})
35
36
        {:error, reason} ->
37
          pairing_error(conn, reason)
28 38
      end
29 39
    else
30 40
      error(conn, :not_found, "computer_controller_disabled")
lib/openagents_web/controllers/issue_controller.ex modified +1 -1

@@ -57,7 +57,7 @@ defmodule OpenAgentsWeb.IssueController do

57 57
    repository = Repositories.get_writable_by_path!(owner, repo, conn.assigns.current_user)
58 58
    issue = Issues.get_issue_by_number!(repository, String.to_integer(issue_number))
59 59
60
    case Issues.update_issue(issue, params) do
60
    case Issues.update_issue(issue, params, conn.assigns.current_user) do
61 61
      {:ok, %Issue{} = issue} ->
62 62
        render(conn, :show, issue: issue, owner: owner, repo: repo)
63 63
lib/openagents_web/controllers/label_controller.ex modified +1 -1

@@ -16,7 +16,7 @@ defmodule OpenAgentsWeb.LabelController do

16 16
  def create(conn, %{"owner" => owner, "repo" => repo} = params) do
17 17
    repository = Repositories.get_writable_by_path!(owner, repo, conn.assigns.current_user)
18 18
19
    case Labels.create_label(repository, params) do
19
    case Labels.create_label(repository, params, conn.assigns.current_user) do
20 20
      {:ok, %Label{} = label} ->
21 21
        conn
22 22
        |> put_status(:created)
lib/openagents_web/controllers/milestone_controller.ex modified +1 -1

@@ -16,7 +16,7 @@ defmodule OpenAgentsWeb.MilestoneController do

16 16
  def create(conn, %{"owner" => owner, "repo" => repo} = params) do
17 17
    repository = Repositories.get_writable_by_path!(owner, repo, conn.assigns.current_user)
18 18
19
    case Milestones.create_milestone(repository, params) do
19
    case Milestones.create_milestone(repository, params, conn.assigns.current_user) do
20 20
      {:ok, %Milestone{} = milestone} ->
21 21
        conn
22 22
        |> put_status(:created)
lib/openagents_web/controllers/project_controller.ex modified +1 -1

@@ -85,7 +85,7 @@ defmodule OpenAgentsWeb.ProjectController do

85 85
      {:ok, issue_number} ->
86 86
        params = Map.put(params, "issue_number", issue_number)
87 87
88
        case Projects.create_project_item(params, project) do
88
        case Projects.create_project_item(params, project, conn.assigns.current_user) do
89 89
          {:ok, item} ->
90 90
            conn
91 91
            |> put_status(:created)
lib/openagents_web/controllers/voice_call_controller.ex modified +17 -1

@@ -3,6 +3,7 @@ defmodule OpenAgentsWeb.VoiceCallController do

3 3
4 4
  import Plug.Conn
5 5
6
  alias OpenAgents.Analytics
6 7
  alias OpenAgents.Conversations
7 8
  alias OpenAgents.Voice.Config
8 9
  alias OpenAgents.Voice.OperationalTelemetry

@@ -19,6 +20,8 @@ defmodule OpenAgentsWeb.VoiceCallController do

19 20
         :ok <- require_no_active_text_turn(conversation),
20 21
         {:ok, _session, admission} <-
21 22
           VoiceSessions.connect(conversation, sdp_offer, safety_identifier, config) do
23
      Analytics.capture("voice_call_started", Analytics.distinct_id(user))
24
22 25
      conn
23 26
      |> put_resp_content_type("application/sdp")
24 27
      |> put_resp_header("cache-control", "no-store")

@@ -74,7 +77,11 @@ defmodule OpenAgentsWeb.VoiceCallController do

74 77
    with %{status: "active"} = user <- conn.assigns.current_user,
75 78
         {:ok, conversation} <- Conversations.ensure_conversation(user),
76 79
         session when not is_nil(session) <- OpenAgents.Voice.active_session(conversation),
77
         {:ok, _ended_session} <- VoiceSessions.end_session(session) do
80
         {:ok, ended_session} <- VoiceSessions.end_session(session) do
81
      Analytics.capture("voice_call_ended", Analytics.distinct_id(user), %{
82
        "duration_ms" => session_duration_ms(ended_session)
83
      })
84
78 85
      send_resp(conn, :no_content, "")
79 86
    else
80 87
      nil ->

@@ -118,6 +125,15 @@ defmodule OpenAgentsWeb.VoiceCallController do

118 125
    |> Base.encode16(case: :lower)
119 126
  end
120 127
128
  defp session_duration_ms(session) do
129
    started_at = Map.get(session, :started_at)
130
    ended_at = Map.get(session, :ended_at)
131
132
    if is_nil(started_at) or is_nil(ended_at),
133
      do: nil,
134
      else: DateTime.diff(ended_at, started_at, :millisecond)
135
  end
136
121 137
  defp require_no_active_text_turn(conversation) do
122 138
    case Conversations.active_turn(conversation) do
123 139
      nil -> :ok
lib/openagents_web/endpoint.ex modified +6

@@ -61,5 +61,11 @@ defmodule OpenAgentsWeb.Endpoint do

61 61
  plug Plug.MethodOverride
62 62
  plug Plug.Head
63 63
  plug Plug.Session, @session_options
64
65
  # Attaches request metadata ($current_url, method, user agent) to analytics
66
  # events captured during the request and reads browser tracing headers.
67
  # Analytics hints only; identity still comes from the session.
68
  plug PostHog.Integrations.Plug
69
64 70
  plug OpenAgentsWeb.Router
65 71
end
lib/openagents_web/live/chat_live.ex modified +39

@@ -2,6 +2,7 @@ defmodule OpenAgentsWeb.ChatLive do

2 2
  use OpenAgentsWeb, :live_view
3 3
4 4
  alias OpenAgents.{
5
    Analytics,
5 6
    Conversations,
6 7
    DataRights,
7 8
    ProfileMemory,

@@ -43,6 +44,8 @@ defmodule OpenAgentsWeb.ChatLive do

43 44
      :ok = Voice.subscribe(conversation)
44 45
      :ok = Work.subscribe(conversation.id)
45 46
      :ok = ComputerActivity.subscribe(conversation.id)
47
48
      Analytics.capture("chat_opened", Analytics.distinct_id(current_user))
46 49
    end
47 50
48 51
    socket =

@@ -176,6 +179,8 @@ defmodule OpenAgentsWeb.ChatLive do

176 179
177 180
  def handle_info({:turn_updated, turn}, socket) do
178 181
    if turn.status in ["completed", "failed", "cancelled"] do
182
      capture_turn_completed(turn, socket)
183
179 184
      # The active turn ended: clear it, surface any error, and immediately start
180 185
      # the next queued message so a stacked run continues without the owner
181 186
      # re-sending.

@@ -374,6 +379,12 @@ defmodule OpenAgentsWeb.ChatLive do

374 379
      {:ok, records} ->
375 380
        _ = Turns.start(records.turn.id)
376 381
382
        Analytics.capture(
383
          "chat_message_sent",
384
          Analytics.distinct_id(socket.assigns.current_user),
385
          %{"length_bucket" => length_bucket(content)}
386
        )
387
377 388
        socket =
378 389
          socket
379 390
          |> stream_insert(:messages, records.user_message)

@@ -502,6 +513,34 @@ defmodule OpenAgentsWeb.ChatLive do

502 513
503 514
  defp first_id([message | _messages]), do: message.id
504 515
  defp first_id([]), do: nil
516
517
  # Terminal turn broadcasts arrive once per turn; the duration comes from the
518
  # turn's own lifecycle timestamps, so no extra query is needed.
519
  defp capture_turn_completed(turn, socket) do
520
    duration_ms =
521
      if is_nil(turn.started_at) or is_nil(turn.completed_at),
522
        do: nil,
523
        else: DateTime.diff(turn.completed_at, turn.started_at, :millisecond)
524
525
    Analytics.capture(
526
      "chat_turn_completed",
527
      Analytics.distinct_id(socket.assigns.current_user),
528
      %{
529
        "outcome" => turn.status,
530
        "duration_ms" => duration_ms
531
      }
532
    )
533
  end
534
535
  defp length_bucket(content) when is_binary(content) do
536
    cond do
537
      byte_size(content) < 100 -> "under_100"
538
      byte_size(content) < 1_000 -> "under_1k"
539
      byte_size(content) < 8_000 -> "under_8k"
540
      true -> "over_8k"
541
    end
542
  end
543
505 544
  defp tool_activity(nil, nil), do: []
506 545
507 546
  defp tool_activity(turn, _voice_session) when not is_nil(turn),
lib/openagents_web/live/issue_index_live.ex modified +1 -1

@@ -52,7 +52,7 @@ defmodule OpenAgentsWeb.IssueIndexLive do

52 52
  end
53 53
54 54
  defp write(socket, id, attrs) do
55
    {:ok, _updated} = Issues.update_issue(issue!(socket, id), attrs)
55
    {:ok, _updated} = Issues.update_issue(issue!(socket, id), attrs, socket.assigns.current_user)
56 56
    {:noreply, load(socket)}
57 57
  end
58 58
lib/openagents_web/live/issue_show_live.ex modified +4 -2

@@ -65,7 +65,7 @@ defmodule OpenAgentsWeb.IssueShowLive do

65 65
    issue = socket.assigns.issue
66 66
    attrs = %{"title" => issue_params["title"], "body" => issue_params["body"]}
67 67
68
    case Issues.update_issue(issue, attrs) do
68
    case Issues.update_issue(issue, attrs, socket.assigns.current_user) do
69 69
      {:ok, updated} ->
70 70
        {:noreply,
71 71
         socket

@@ -138,7 +138,9 @@ defmodule OpenAgentsWeb.IssueShowLive do

138 138
139 139
  defp set_state(socket, state, reason) do
140 140
    attrs = %{"state" => state, "state_reason" => reason}
141
    {:ok, updated} = Issues.update_issue(socket.assigns.issue, attrs)
141
142
    {:ok, updated} =
143
      Issues.update_issue(socket.assigns.issue, attrs, socket.assigns.current_user)
142 144
143 145
    flash = if state == "closed", do: "Issue closed", else: "Issue reopened"
144 146
lib/openagents_web/live/label_index_live.ex modified +1 -1

@@ -22,7 +22,7 @@ defmodule OpenAgentsWeb.LabelIndexLive do

22 22
  end
23 23
24 24
  def handle_event("save", %{"label" => label_params}, socket) do
25
    case Labels.create_label(socket.assigns.repository, label_params) do
25
    case Labels.create_label(socket.assigns.repository, label_params, socket.assigns.current_user) do
26 26
      {:ok, _label} ->
27 27
        {:noreply,
28 28
         socket
lib/openagents_web/live/memory_live.ex modified +6 -1

@@ -19,6 +19,7 @@ defmodule OpenAgentsWeb.MemoryLive do

19 19
20 20
  use OpenAgentsWeb, :live_view
21 21
22
  alias OpenAgents.Analytics
22 23
  alias OpenAgents.Conversations
23 24
  alias OpenAgents.DataRights
24 25
  alias OpenAgents.ProfileMemory

@@ -29,7 +30,11 @@ defmodule OpenAgentsWeb.MemoryLive do

29 30
    {:ok, conversation} = Conversations.ensure_conversation(current_user)
30 31
    owner = Conversations.get_conversation_owner!(conversation)
31 32
32
    if connected?(socket), do: :ok = ProfileMemory.subscribe(owner)
33
    if connected?(socket) do
34
      :ok = ProfileMemory.subscribe(owner)
35
36
      Analytics.capture("memory_viewed", Analytics.distinct_id(current_user))
37
    end
33 38
34 39
    {:ok,
35 40
     socket
lib/openagents_web/live/milestone_index_live.ex modified +5 -1

@@ -23,7 +23,11 @@ defmodule OpenAgentsWeb.MilestoneIndexLive do

23 23
  end
24 24
25 25
  def handle_event("save", %{"milestone" => milestone_params}, socket) do
26
    case Milestones.create_milestone(socket.assigns.repository, milestone_params) do
26
    case Milestones.create_milestone(
27
           socket.assigns.repository,
28
           milestone_params,
29
           socket.assigns.current_user
30
         ) do
27 31
      {:ok, _milestone} ->
28 32
        {:noreply,
29 33
         socket
lib/openagents_web/live/project_show_live.ex modified +2 -1

@@ -41,7 +41,8 @@ defmodule OpenAgentsWeb.ProjectShowLive do

41 41
42 42
    case Projects.create_project_item(
43 43
           %{"issue_number" => number, "values" => %{"Status" => status}},
44
           project
44
           project,
45
           socket.assigns.current_user
45 46
         ) do
46 47
      {:ok, _item} ->
47 48
        {:noreply,
lib/openagents_web/plugs/content_security_policy.ex modified +3 -1

@@ -27,7 +27,9 @@ defmodule OpenAgentsWeb.Plugs.ContentSecurityPolicy do

27 27
      [
28 28
        "default-src 'self'",
29 29
        "base-uri 'self'",
30
        "connect-src 'self' ws: wss:",
30
        # The PostHog ingest and asset hosts carry browser analytics batches
31
        # (docs/2026-08-21-posthog-integration-runbook.md).
32
        "connect-src 'self' ws: wss: https://us.i.posthog.com https://us-assets.i.posthog.com",
31 33
        "frame-ancestors 'none'",
32 34
        "img-src 'self' data: https://avatars.githubusercontent.com",
33 35
        "object-src 'none'",
lib/openagents_web/plugs/posthog_bootstrap.ex added +25

@@ -0,0 +1,25 @@

1
defmodule OpenAgentsWeb.Plugs.PostHogBootstrap do
2
  @moduledoc """
3
  Assigns the browser analytics bootstrap values the root layout renders as
4
  data attributes.
5
6
  The token and host come from boot configuration; the identity comes from the
7
  `posthog_identity` session key written at login, so no database query runs on
8
  the render path. When capture is unconfigured, `posthog_enabled` is false and
9
  the browser bundle skips initialization entirely.
10
  """
11
12
  use Plug.Builder
13
14
  import Plug.Conn
15
16
  plug :assign_posthog_bootstrap
17
18
  defp assign_posthog_bootstrap(conn, _opts) do
19
    conn
20
    |> assign(:posthog_enabled, OpenAgents.Analytics.enabled?())
21
    |> assign(:posthog_token, Application.get_env(:openagents, :posthog_project_token))
22
    |> assign(:posthog_api_host, Application.get_env(:openagents, :posthog_api_host))
23
    |> assign(:posthog_identity, get_session(conn, "posthog_identity"))
24
  end
25
end
lib/openagents_web/router.ex modified +2

@@ -16,6 +16,8 @@ defmodule OpenAgentsWeb.Router do

16 16
17 17
    plug OpenAgentsWeb.Plugs.ContentSecurityPolicy
18 18
19
    plug OpenAgentsWeb.Plugs.PostHogBootstrap
20
19 21
    plug :fetch_current_user
20 22
    plug OpenAgentsWeb.Plugs.SidebarSections
21 23
  end
mix.exs modified +1

@@ -112,6 +112,7 @@ defmodule OpenAgents.MixProject do

112 112
      {:websockex, "~> 0.5.1"},
113 113
      {:telemetry_metrics, "~> 1.0"},
114 114
      {:telemetry_poller, "~> 1.0"},
115
      {:posthog, "~> 2.0"},
115 116
      {:gettext, "~> 1.0"},
116 117
      {:jason, "~> 1.2"},
117 118
      {:dns_cluster, "~> 0.2.0"},
mix.lock modified +4

@@ -25,6 +25,7 @@

25 25
  "jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"},
26 26
  "lazy_html": {:hex, :lazy_html, "0.1.12", "31a55ee622918fce988c94b06232227b42daa64e4eab14ac32081d0f3fd8db6f", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "8a0da594776caee58782c6f93b2abaa5bdb809daf8d43351a561f7de9dc2e2a8"},
27 27
  "libring": {:hex, :libring, "1.7.0", "4f245d2f1476cd7ed8f03740f6431acba815401e40299208c7f5c640e1883bda", [:mix], [], "hexpm", "070e3593cb572e04f2c8470dd0c119bc1817a7a0a7f88229f43cf0345268ec42"},
28
  "logger_json": {:hex, :logger_json, "7.0.4", "e315f2b9a755504658a745f3eab90d88d2cd7ac2ecfd08c8da94d8893965ab5c", [:mix], [{:decimal, ">= 0.0.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:ecto, "~> 3.11", [hex: :ecto, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: true]}], "hexpm", "d1369f8094e372db45d50672c3b91e8888bcd695fdc444a37a0734e96717c45c"},
28 29
  "mdex": {:hex, :mdex, "0.13.5", "c1c94d230ccaab01ad0c68090d3b31613c10ece1844f32b55895da4ce0c63029", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:lumis, "~> 0.1", [hex: :lumis, repo: "hexpm", optional: true]}, {:mdex_native, ">= 0.2.6", [hex: :mdex_native, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.0 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}], "hexpm", "c57409fb6b34fbc58fbce0a6da670c9a4b5a2e94f86abdc56e9e213ed74620f2"},
29 30
  "mdex_native": {:hex, :mdex_native, "0.2.8", "20b7cbf330c1ca81b8da4132b8d01952cded11f6dfc2abe8fef25c13681b15e4", [:mix], [{:rustler, "~> 0.32", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "004a5565b6c96a06400901eb1e4e603585e00b23262d3f595c3f4aa38b83ef66"},
30 31
  "merkle_map": {:hex, :merkle_map, "0.2.2", "f36ff730cca1f2658e317a3c73406f50bbf5ac8aff54cf837d7ca2069a6e251c", [:mix], [], "hexpm", "383107f0503f230ac9175e0631647c424efd027e89ea65ab5ea12eeb54257aaf"},

@@ -32,6 +33,7 @@

32 33
  "mint": {:hex, :mint, "1.9.3", "3337184d69179695c7a9f1714d92c11e629d36c8c037a21cf490131d3d150554", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "5f7c9342480c069dbbc4eeac3490303c9e01870ff01a7f1d29b6107054fc1e74"},
33 34
  "mix_audit": {:hex, :mix_audit, "2.1.5", "c0f77cee6b4ef9d97e37772359a187a166c7a1e0e08b50edf5bf6959dfe5a016", [:make, :mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}], "hexpm", "87f9298e21da32f697af535475860dc1d3617a010e0b418d2ec6142bc8b42d69"},
34 35
  "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
36
  "nimble_ownership": {:hex, :nimble_ownership, "1.0.2", "fa8a6f2d8c592ad4d79b2ca617473c6aefd5869abfa02563a77682038bf916cf", [:mix], [], "hexpm", "098af64e1f6f8609c6672127cfe9e9590a5d3fcdd82bc17a377b8692fd81a879"},
35 37
  "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
36 38
  "nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
37 39
  "phoenix": {:hex, :phoenix, "1.8.11", "9bb8f0c4e9f0b9eaf7f34cf8cb0bd25bd8599763a5587d64918a9ebb04027823", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 2.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "44f028f4129e5a29487e868f84903373e3d032da151ad0c789c3849f464e7351"},

@@ -45,6 +47,7 @@

45 47
  "plug": {:hex, :plug, "1.20.3", "56c480c633ec2ce10140e236e15233bf576e1d323887d7c96711bd02ab5160db", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "be266aee1b8536ef6409d58cf39a3121319f0ec47cfa1b24024485aa0e76ad76"},
46 48
  "plug_crypto": {:hex, :plug_crypto, "2.2.0", "144014737daaf485407f5ed77daeaad74d651b216a28c87543f8cc7043f8efc8", [:mix], [], "hexpm", "83a95744ab1c75876542b6fab135fcc176280e0f301a111c1f757fddcec95d2c"},
47 49
  "postgrex": {:hex, :postgrex, "0.22.4", "d271f595dfd25230b6398354e19d17bb5e2d20130fd2d9bdca7e15f125d43552", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "4aae45a2d60e35b04eea2602440be152fae332901f1fc7a60fc7cb7f0f9a9c5a"},
50
  "posthog": {:hex, :posthog, "2.14.1", "2d71128e17129f2c04c8fd7142ba5af56874572ff443848675494ba24997a066", [:mix], [{:logger_json, "~> 7.0", [hex: :logger_json, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.1", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_ownership, "~> 1.0", [hex: :nimble_ownership, repo: "hexpm", optional: false]}, {:req, ">= 0.6.1 and < 1.0.0", [hex: :req, repo: "hexpm", optional: false]}, {:uuid_v7, "~> 0.6", [hex: :uuid_v7, repo: "hexpm", optional: false]}], "hexpm", "68c5e77e5b9f349e62c4640dcf9fabad000f9b265e2990b64eca3309016730ce"},
48 51
  "ra": {:hex, :ra, "2.17.3", "a72a4b4d1517fdf4168bfbd49a2fa9c2e77dcd17434dd3aa0da9daf6cad49329", [:rebar3], [{:aten, "0.6.0", [hex: :aten, repo: "hexpm", optional: false]}, {:gen_batch_server, "0.8.9", [hex: :gen_batch_server, repo: "hexpm", optional: false]}, {:seshat, "1.0.1", [hex: :seshat, repo: "hexpm", optional: false]}], "hexpm", "f4de530341bf416e43768fea56c72f08a074dd87c80d116f84a6e2e8277cc06a"},
49 52
  "req": {:hex, :req, "0.7.3", "b141f1b465dabc5fb8ce67bd2f15a85fc80f6c75719910699560e32ce49f62ef", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:finch, "~> 0.21", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "73b303030dccc2b6d023ee5ada380825ab3a7cd3863aead493db09ec420ffdf2"},
50 53
  "rustler_precompiled": {:hex, :rustler_precompiled, "0.9.0", "3a052eda09f3d2436364645cc1f13279cf95db310eb0c17b0d8f25484b233aa0", [:mix], [{:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "471d97315bd3bf7b64623418b3693eedd8e47de3d1cb79a0ac8f9da7d770d94c"},

@@ -55,6 +58,7 @@

55 58
  "telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"},
56 59
  "telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"},
57 60
  "thousand_island": {:hex, :thousand_island, "1.5.0", "f50a213cac97262b6d5ebb85745aa2c00fec1413191e6e66834788d45425cecb", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "708923d40523e43cf99041ab37a0d4b0ec426ac6438fa3716ab23d919eaeb412"},
61
  "uuid_v7": {:hex, :uuid_v7, "0.6.0", "1d65727ade8ca619ed40fdef90c4186b50c84657d2b412f7cb79777ab2d47559", [:mix], [{:ecto, "~> 3.0", [hex: :ecto, repo: "hexpm", optional: true]}], "hexpm", "1dc401134e61da847a7b2a3b28d2593893f457b9f2704893b1ba3ff7946ce91f"},
58 62
  "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
59 63
  "websock_adapter": {:hex, :websock_adapter, "0.6.0", "73db5ab8aaefd1a876a97ce3e6afc96562625de69ef17a4e04426e034849d0b8", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "50021a85bce8f203b086705d9e0c5415e2c7eb05d319111b0428fe71f9934617"},
60 64
  "websockex": {:hex, :websockex, "0.5.1", "9de28d37bbe34f371eb46e29b79c94c94fff79f93c960d842fbf447253558eb4", [:mix], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "8ef39576ed56bc3804c9cd8626f8b5d6b5721848d2726c0ccd4f05385a3c9f14"},
test/openagents/analytics_test.exs added +176

@@ -0,0 +1,176 @@

1
defmodule OpenAgents.AnalyticsTest do
2
  use ExUnit.Case, async: false
3
4
  alias OpenAgents.Analytics
5
6
  # A sink that forwards every capture to the test process so assertions stay
7
  # in-process and the suite never touches the network.
8
  defmodule TestSink do
9
    def capture(event, distinct_id, properties) do
10
      send(:analytics_test_process, {:captured, event, distinct_id, properties})
11
      :ok
12
    end
13
  end
14
15
  setup do
16
    Process.register(self(), :analytics_test_process)
17
18
    original_token = Application.get_env(:openagents, :posthog_project_token)
19
    original_sink = Application.get_env(:openagents, :analytics_sink)
20
21
    Application.put_env(:openagents, :posthog_project_token, "phc_test_token")
22
    Application.put_env(:openagents, :analytics_sink, TestSink)
23
24
    on_exit(fn ->
25
      restore_env(:openagents, :posthog_project_token, original_token)
26
      restore_env(:openagents, :analytics_sink, original_sink)
27
    end)
28
29
    :ok
30
  end
31
32
  defp restore_env(app, key, :unset), do: Application.delete_env(app, key)
33
  defp restore_env(app, key, value), do: Application.put_env(app, key, value)
34
35
  defp captured do
36
    assert_receive {:captured, event, distinct_id, properties}, 500
37
    %{event: event, distinct_id: distinct_id, properties: properties}
38
  end
39
40
  describe "capture/3 when configured" do
41
    test "dispatches through the sink with standard properties" do
42
      :ok = Analytics.capture("user_signed_up", "user_abc", %{"github_login" => "octocat"})
43
44
      captured = captured()
45
46
      assert captured.event == "user_signed_up"
47
      assert captured.distinct_id == "user_abc"
48
      assert captured.properties["github_login"] == "octocat"
49
      assert is_binary(captured.properties["environment"])
50
      assert is_binary(captured.properties["app_revision"])
51
      assert captured.properties["surface"] == "server"
52
    end
53
54
    test "a caller-provided surface survives" do
55
      :ok = Analytics.capture("issue_created", "user_abc", %{"surface" => "api"})
56
      assert captured().properties["surface"] == "api"
57
    end
58
59
    test "sensitive keys are dropped before dispatch" do
60
      :ok =
61
        Analytics.capture("event", "user_abc", %{
62
          "token" => "leaked",
63
          "github_secret" => "leaked",
64
          "password" => "leaked",
65
          "safe_property" => "kept"
66
        })
67
68
      properties = captured().properties
69
70
      refute Map.has_key?(properties, "token")
71
      refute Map.has_key?(properties, "github_secret")
72
      refute Map.has_key?(properties, "password")
73
      assert properties["safe_property"] == "kept"
74
    end
75
76
    test "oversized values are truncated to a marker" do
77
      :ok = Analytics.capture("event", "user_abc", %{"big" => String.duplicate("x", 2_000)})
78
      assert captured().properties["big"] == "[truncated]"
79
    end
80
81
    test "nested maps are bounded by depth and entry count" do
82
      deep = %{"a" => %{"a" => %{"a" => %{"a" => %{"a" => %{"a" => 1}}}}}}
83
84
      wide = Map.new(1..60, fn i -> {"entry_#{i}", i} end)
85
86
      :ok = Analytics.capture("event", "user_abc", %{"deep" => deep, "wide" => wide})
87
88
      properties = captured().properties
89
90
      assert map_size(properties["wide"]) <= 20
91
92
      refute match?(
93
               %{"deep" => %{"a" => %{"a" => %{"a" => %{"a" => _}}}}},
94
               properties
95
             )
96
    end
97
98
    test "non-scalar shapes are dropped" do
99
      :ok =
100
        Analytics.capture("event", "user_abc", %{"struct" => URI.parse("https://example.com")})
101
102
      refute Map.has_key?(captured().properties, "struct")
103
    end
104
105
    test "a raising sink never propagates" do
106
      Application.put_env(:openagents, :analytics_sink, RaisingSink)
107
      :ok = Analytics.capture("event", "user_abc", %{})
108
    end
109
  end
110
111
  describe "capture/3 when unconfigured" do
112
    test "no-ops without touching the sink" do
113
      Application.put_env(:openagents, :posthog_project_token, nil)
114
115
      send_to_self = fn event, distinct_id, properties ->
116
        send(:analytics_test_process, {:captured, event, distinct_id, properties})
117
      end
118
119
      :ok = Analytics.capture("event", "user_abc")
120
121
      refute_receive {:captured, _, _, _}, 100
122
      assert is_function(send_to_self)
123
    end
124
125
    test "blank tokens count as unconfigured" do
126
      Application.put_env(:openagents, :posthog_project_token, "   ")
127
      :ok = Analytics.capture("event", "user_abc")
128
      refute_receive {:captured, _, _, _}, 100
129
    end
130
  end
131
132
  describe "distinct_id/1" do
133
    test "prefixes an account id" do
134
      assert Analytics.distinct_id("0f0e0d0c-1111-2222-3333-444455556666") ==
135
               "user_0f0e0d0c-1111-2222-3333-444455556666"
136
    end
137
138
    test "derives from a struct with an id" do
139
      assert Analytics.distinct_id(%{id: "abc"}) == "user_abc"
140
    end
141
142
    test "passes prefixed identifiers through unchanged" do
143
      assert Analytics.distinct_id("system_forge") == "system_forge"
144
      assert Analytics.distinct_id("visitor_xyz") == "visitor_xyz"
145
      assert Analytics.distinct_id("anonymous") == "anonymous"
146
    end
147
148
    test "system ids are stable per surface" do
149
      assert Analytics.system_distinct_id("forge") == "system_forge"
150
    end
151
  end
152
153
  describe "browser_distinct_id/1" do
154
    import Plug.Conn
155
156
    test "reads the tracing header when present" do
157
      conn = put_req_header(build_conn(), "x-posthog-distinct-id", "browser-123")
158
      assert Analytics.browser_distinct_id(conn) == "browser-123"
159
    end
160
161
    test "falls back to anonymous" do
162
      assert Analytics.browser_distinct_id(build_conn()) == "anonymous"
163
    end
164
165
    test "rejects oversized header values" do
166
      conn = put_req_header(build_conn(), "x-posthog-distinct-id", String.duplicate("x", 300))
167
      assert Analytics.browser_distinct_id(conn) == "anonymous"
168
    end
169
170
    defp build_conn, do: %Plug.Conn{}
171
  end
172
end
173
174
defmodule RaisingSink do
175
  def capture(_event, _distinct_id, _properties), do: raise("sink exploded")
176
end

This page updates live while a promote is in flight · changelog