diff --git a/.claude/skills/canvas-templates/SKILL.md b/.claude/skills/canvas-templates/SKILL.md index 9bc9490e74..f1006dc300 100644 --- a/.claude/skills/canvas-templates/SKILL.md +++ b/.claude/skills/canvas-templates/SKILL.md @@ -103,6 +103,40 @@ iframe hold a token — it posts a request; the host runs the authenticated call > anonymous viewers, so publish converts validated query nodes → saved insights + > an allowlist and the canvas references them by id. Implement it there, not in edit. +## Live / realtime (SSE) + +Two more `ph.*` methods cover **live** data — a streaming counterpart to the +request/response reads. Use them when a canvas must update as events arrive +(live event feed, users-online counter) instead of re-polling an insight: + +- **`ph.subscribeLiveEvents(params, onEvent, onEnd)`** — a live SSE subscription, + NOT request/response. The canvas posts a `live-subscribe` frame; the host + renderer asks the host for the brokered connection config + (`canvasData.liveConnectionConfig`: `{ eventsUrl, statsUrl, token }`), opens + the SSE to the **livestream service** (`live.{region}.posthog.com/events`) + itself (`openLiveEventsStream` in `freeformDataBridge.ts`), and fans each + event into the iframe as a `live-event` postMessage frame. The scoped JWT + lives only in the renderer — it never crosses into the sandbox. Extra + postMessage frame types (`live-subscribe` / `live-unsubscribe` from canvas, + `live-event` / `live-stream-status` from host) extend the protocol unions in + `freeformSchemas.ts`. `params.properties` filters only support the livestream + allowlist (`exact|is_not|icontains|not_icontains|regex|not_regex|gt|gte|lt| + lte|is_set|is_not_set`, the `LIVE_EVENTS_SUPPORTED_OPERATORS` const) — the + prompt tells the agent that; validation lives in `freeformSchemas.ts`. +- **`await ph.liveStats()`** — a ONE-SHOT poll (rides the generic `data-request` + frame with `method: "liveStats"`) → `canvasData.liveStats` → + `CanvasDataService.liveStats()` → `GET /stats` with the JWT → + `{ users_on_product, active_recordings }`. Poll on an interval from the + canvas; NOT a stream. The derived **sliding-window "users online"** count + (60s window over `distinct_id`s from the event stream) is canvas-side JS — + no extra host method needed; the prompt spells out the map+prune pattern. + +The live host comes from the region: `getLiveEventsUrlFromRegion` in +`packages/shared/src/urls.ts` (`us`→`live.us.posthog.com`, `eu`→ +`live.eu.posthog.com`, `dev`→`localhost:8666`). The JWT is the project's +`live_events_token` (Django-minted, `aud=posthog:livestream`, ~24h TTL), fetched +server-side by `CanvasDataService` and cached per project. + ## Dates The freeform app owns its date control (the toolbar picker is hidden for freeform — diff --git a/packages/core/src/canvas/canvasDataService.ts b/packages/core/src/canvas/canvasDataService.ts index 9ac998e9f6..8cbd3abe6d 100644 --- a/packages/core/src/canvas/canvasDataService.ts +++ b/packages/core/src/canvas/canvasDataService.ts @@ -5,6 +5,7 @@ import { type RootLogger, type ScopedLogger, } from "@posthog/di/logger"; +import { getLiveEventsUrlFromRegion } from "@posthog/shared"; import { inject, injectable } from "inversify"; import type { CanvasCaptureConfig, @@ -12,6 +13,8 @@ import type { CanvasCaptureResult, CanvasDataQueryInput, CanvasDataResult, + CanvasLiveConnectionConfig, + CanvasLiveStats, CanvasLoadInsightInput, } from "./freeformSchemas"; import { @@ -44,6 +47,10 @@ export class CanvasDataService { // projects in the same session doesn't reuse the previous project's key (this // is a singleton service). private readonly projectTokens = new Map(); + // Project-scoped live-events JWTs (Django-minted, aud=posthog:livestream). + // Keyed by project id like `projectTokens` — the host holds these only to + // open the livestream SSE; they never cross into the iframe. + private readonly liveEventsTokens = new Map(); // The signed-in user's distinct_id, the default attribution in edit mode. // Per-user (not per-project), so a single cached value is correct. private userDistinctId: string | undefined; @@ -173,6 +180,45 @@ export class CanvasDataService { return { ok: true }; } + // The brokered connection details for the live-events SSE stream. The token + // is the project's `live_events_token` (a scoped JWT minted by Django with + // `aud=posthog:livestream`, ~24h TTL) — the host renderer holds it just long + // enough to open `/events` and fan events into the canvas iframe; it never + // enters the sandbox. The live host is derived from the account's cloud + // region (us.eu → live.us.eu.posthog.com; dev → localhost:8666), mirroring + // the frontend's `liveEventsHostOrigin()`. + async liveConnectionConfig(): Promise { + const { apiHost } = await this.getAuthContext(); + const origin = getLiveEventsUrlFromRegion(this.getRegion()); + const token = await this.getLiveEventsToken(apiHost); + return { + eventsUrl: `${origin}/events`, + statsUrl: `${origin}/stats`, + token, + }; + } + + // The project's realtime counters (`users_on_product`, `active_recordings`) + // from the live-events `/stats` endpoint. The same authenticated fetch the + // app's "users online" pill makes — scoped JWT, no project id needed (the + // JWT itself identifies the project). + async liveStats(): Promise { + const { apiHost } = await this.getAuthContext(); + const origin = getLiveEventsUrlFromRegion(this.getRegion()); + const token = await this.getLiveEventsToken(apiHost); + const response = await fetch(`${origin}/stats`, { + headers: { Authorization: `Bearer ${token}` }, + }); + if (!response.ok) { + const err = new Error(`Live stats request failed (${response.status})`); + this.log.warn("Canvas live stats failed", { + status: response.status, + }); + throw err; + } + return (await response.json()) as CanvasLiveStats; + } + // The project's public capture key. Fetched from the authenticated project // endpoint (which the user can already read) and cached; capture itself uses // the public key, not the bearer token. @@ -195,6 +241,18 @@ export class CanvasDataService { return data.api_token; } + // Resolve the API host for server-side live calls (token validity is enforced + // by `getValidAccessToken`; we never need the raw token itself here since the + // livestream service uses the project-scoped JWT). + private async getAuthContext(): Promise<{ apiHost: string }> { + const { apiHost } = await this.authService.getValidAccessToken(); + const projectId = this.authService.getState().currentProjectId; + if (projectId == null) { + throw new Error("No PostHog project selected"); + } + return { apiHost }; + } + // The signed-in user's distinct_id (so edit-mode captures attribute to "me" in // PostHog, not a placeholder). Cached; returns undefined if unavailable. private async getUserDistinctId(): Promise { @@ -203,4 +261,43 @@ export class CanvasDataService { this.userDistinctId = user?.distinctId; return this.userDistinctId; } + + // The account's cloud region ("us" | "eu" | "dev"), used to derive the + // live-events host. Direct session state (`getValidAccessToken` doesn't + // expose the region); defaults to "us" when the session is still resolving. + private getRegion(): "us" | "eu" | "dev" { + return ( + ( + this.authService.getState() as { + cloudRegion?: "us" | "eu" | "dev" | null; + } + ).cloudRegion ?? "us" + ); + } + + // The project's scoped `live_events_token` JWT (minted by Django, ~24h TTL). + // Fetched from the authenticated project endpoint and cached per project. + private async getLiveEventsToken(apiHost: string): Promise { + const projectId = this.authService.getState().currentProjectId; + if (projectId == null) { + throw new Error("No PostHog project selected"); + } + const cached = this.liveEventsTokens.get(projectId); + if (cached) return cached; + const response = await this.authService.authenticatedFetch( + fetch, + `${apiHost}/api/environments/${projectId}/`, + ); + if (!response.ok) { + throw new Error(`Couldn't read live events token (${response.status})`); + } + const data = (await response.json()) as { + live_events_token?: string | null; + }; + if (!data.live_events_token) { + throw new Error("Project has no live events token"); + } + this.liveEventsTokens.set(projectId, data.live_events_token); + return data.live_events_token; + } } diff --git a/packages/core/src/canvas/canvasTemplates.ts b/packages/core/src/canvas/canvasTemplates.ts index 23ccc010f3..53c1027aeb 100644 --- a/packages/core/src/canvas/canvasTemplates.ts +++ b/packages/core/src/canvas/canvasTemplates.ts @@ -51,6 +51,12 @@ const FREEFORM_BASE = [ '- `ph.openExternal(url)` asks the host to open an absolute `https://posthog.com` (or `*.posthog.com`) URL — anything else is blocked, so do NOT link to other sites. Call it only from a user interaction (e.g. a click handler); the host ignores opens while the canvas is not focused, so calling it on load/in effects does nothing. Sandboxed `target="_blank"` navigation is intentionally blocked.', "- Session replay, $session_id, and person attribution are handled automatically by the host's posthog-js running in the sandbox — you do NOT set session ids or initialise recording; just call ph.capture for custom events.", "- Load data inside `useEffect` with `useState`; show a loading state first, then render. Handle the empty/error case. Keep result sets small — aggregate in the query, don't fetch raw event dumps.", + "", + "LIVE / REALTIME — for anything that must update as events arrive (a live event feed, a users-online counter), use the streaming live events API. This is DIFFERENT from a saved insight: insights are historical snapshots, live events stream in real time from the livestream service:", + "- `ph.subscribeLiveEvents(params, onEvent, onEnd)` opens a live SSE subscription and calls `onEvent(event)` for every matching event as it lands. `params`: `{ eventType?: string | null, distinctId?: string | null, columns?: string[] | null, properties?: Array<{ key, operator, value? }> | null }`. IMPORTANT: `properties` filters ONLY support these operators — `exact | is_not | icontains | not_icontains | regex | not_regex | gt | gte | lt | lte | is_set | is_not_set` — any other operator fails. `value` may be a scalar or an array (array = OR). The host holds the live JWT; you never see a token.", + "- Each event is `{ distinct_id, event, timestamp, properties }`. You MUST keep the returned subscription and CLOSE it: `const sub = ph.subscribeLiveEvents(...); …; sub.close()` — close it in a useEffect cleanup (and before re-subscribing with new params) so stale streams don't pile up. `onEnd(status, message)` fires when the stream closes/errors — resubscribe from there if you want to retry.", + "- `await ph.liveStats()` resolves to the project's realtime counters `{ users_on_product, active_recordings }` — the same \"users online\" pill the PostHog Activity page shows. It's a ONE-SHOT poll, NOT a stream: poll it on an interval (e.g. `setInterval` 30s) and clear that interval on unmount.", + '- "Users online" pattern (mirror the app): combine BOTH — the server counter from `ph.liveStats()` AND a client-side sliding-window count you derive from the event stream. Keep `useRef(new Map())` of `distinct_id → unix-ts`, on each event `map.set(event.distinct_id, Date.parse(event.timestamp)/1000)`, and every ~5s drop entries older than 60s (`FILTERED_LIVE_USER_WINDOW_SECONDS` = 60) then set the count to `map.size`. That gives a live count that decays within seconds, exactly like PostHog\'s Web Analytics Live tab.', ]; const FREEFORM_STYLE = [ diff --git a/packages/core/src/canvas/freeformSchemas.test.ts b/packages/core/src/canvas/freeformSchemas.test.ts index 7f1a8381e7..f37c5af2bc 100644 --- a/packages/core/src/canvas/freeformSchemas.test.ts +++ b/packages/core/src/canvas/freeformSchemas.test.ts @@ -1,5 +1,10 @@ import { describe, expect, it } from "vitest"; -import { canvasToHostMessageSchema } from "./freeformSchemas"; +import { + canvasLiveEventsSubscribeInput, + canvasToHostMessageSchema, + hostToCanvasMessageSchema, + liveEventPropertyFilterSchema, +} from "./freeformSchemas"; describe("canvasToHostMessageSchema open-external", () => { const message = (url: string) => ({ @@ -33,3 +38,70 @@ describe("canvasToHostMessageSchema open-external", () => { ); }); }); + +describe("live-event message round-trip", () => { + it("accepts a live-subscribe frame with rich filters", () => { + const parsed = canvasToHostMessageSchema.safeParse({ + channel: "posthog-canvas", + type: "live-subscribe", + subId: "live-1", + params: { + eventType: "$pageview", + columns: ["$current_url", "$browser"], + properties: [ + { key: "$browser", operator: "exact", value: "Chrome" }, + { + key: "$current_url", + operator: "icontains", + value: ["docs", "blog"], + }, + { key: "$geoip_country_code", operator: "is_set" }, + ], + }, + }); + expect(parsed.success).toBe(true); + }); + + it("rejects a subscribe frame whose operator is outside the live allowlist", () => { + const parsed = liveEventPropertyFilterSchema.safeParse({ + key: "$browser", + operator: "contains_everything", // not a livestream-supported operator + value: "x", + }); + expect(parsed.success).toBe(false); + }); + + it("accepts a live-event frame back into the iframe", () => { + const parsed = hostToCanvasMessageSchema.safeParse({ + channel: "posthog-canvas", + type: "live-event", + subId: "live-1", + event: { + distinct_id: "user@example.com", + event: "$pageview", + timestamp: "2026-07-30T10:00:00.000Z", + properties: { $current_url: "https://posthog.com/docs" }, + }, + }); + expect(parsed.success).toBe(true); + }); + + it("accepts a live-stream-status frame", () => { + expect( + hostToCanvasMessageSchema.safeParse({ + channel: "posthog-canvas", + type: "live-stream-status", + subId: "live-1", + status: "error", + message: "upstream closed", + }).success, + ).toBe(true); + }); + + it("rejects subscribe params with extra/garbage fields", () => { + const parsed = canvasLiveEventsSubscribeInput.safeParse({ + eventType: 42, // must be a string + }); + expect(parsed.success).toBe(false); + }); +}); diff --git a/packages/core/src/canvas/freeformSchemas.ts b/packages/core/src/canvas/freeformSchemas.ts index dcc41fc70f..eef7c7db9d 100644 --- a/packages/core/src/canvas/freeformSchemas.ts +++ b/packages/core/src/canvas/freeformSchemas.ts @@ -130,6 +130,109 @@ export const canvasCaptureConfigSchema = z.object({ }); export type CanvasCaptureConfig = z.infer; +// --------------------------------------------------------------------------- +// Live events avenue: a streaming counterpart to the request/response data +// requests. A freeform canvas CANNOT hold the project's `live_events_token` +// (a project-scoped credential, deliberately never shipped into the null-origin +// iframe — same rule as the read token). Instead the canvas sends a subscribe +// request; the HOST opens the SSE connection to the live-events service +// (`live.{region}.posthog.com/events`) with the brokered JWT and fans each +// event into the iframe as a `live-event` frame. The same brokered avenue serves +// `live-stats` (the project's realtime `users_on_product` / `active_recordings` +// counters from `/stats`) on demand. Only the region's public live HOST and the +// (non-credential) subscription params cross into the iframe. +// --------------------------------------------------------------------------- + +// The property-filter operators the live-events service supports. THIS LIST IS +// AUTHORITATIVE over the frontend's richer `PropertyOperator` set (the +// livestream service only compiles a fixed operator allowlist) — mirroring +// `LIVE_EVENTS_SUPPORTED_OPERATORS` in the app's Activity → Live events logic, +// and the canvas prompt tells the agent not to invent others. Values are the +// operator ids as the service expects them. +export const LIVE_EVENTS_SUPPORTED_OPERATORS = [ + "exact", + "is_not", + "icontains", + "not_icontains", + "regex", + "not_regex", + "gt", + "gte", + "lt", + "lte", + "is_set", + "is_not_set", +] as const; +export type LiveEventsOperator = + (typeof LIVE_EVENTS_SUPPORTED_OPERATORS)[number]; + +// A single property filter on the live stream: an event-property `key` +// matched by `operator` against `value` (omit `value` for `is_set`/`is_not_set`). +// `value` may be a scalar or an array of scalars (array => OR, same as the app). +export const liveEventPropertyFilterSchema = z.object({ + key: z.string().min(1), + operator: z.enum(LIVE_EVENTS_SUPPORTED_OPERATORS), + value: z + .union([ + z.string(), + z.number(), + z.boolean(), + z.array(z.union([z.string(), z.number(), z.boolean()])), + ]) + .optional(), +}); +export type LiveEventPropertyFilter = z.infer< + typeof liveEventPropertyFilterSchema +>; + +// What the canvas subscribes to. `eventType` filters to a single event name +// (omit = all events); `properties` are AND-ed rich filters (same allowlist as +// the app's live feed); `columns` is a properties whitelist (empty/absent = all); +// `distinctId` restricts to one actor. Mirrors the app's live feed params. +export const canvasLiveEventsSubscribeInput = z.object({ + eventType: z.string().min(1).nullish(), + distinctId: z.string().min(1).nullish(), + columns: z.array(z.string()).nullish(), + properties: z.array(liveEventPropertyFilterSchema).nullish(), +}); +export type CanvasLiveEventsSubscribeInput = z.infer< + typeof canvasLiveEventsSubscribeInput +>; + +// A single live event delivered to the canvas. Shape matches the livestream +// service's `ResponsePostHogEvent`; the canvas ALSO derives a client-side +// "users online" sliding-window count from `distinct_id`s on these events. +export const liveEventSchema = z.object({ + id: z.string().optional(), + distinct_id: z.string(), + event: z.string(), + timestamp: z.string(), + properties: z.record(z.string(), z.unknown()).optional(), +}); +export type LiveEvent = z.infer; + +// The realtime counters the project's `/stats` endpoint returns — the same +// "N users currently online" pill and active-recordings count the app shows. +export const canvasLiveStatsSchema = z.object({ + users_on_product: z.number().optional(), + active_recordings: z.number().optional(), +}); +export type CanvasLiveStats = z.infer; + +// The brokered connection details the host renderer uses to open the SSE +// stream. `eventsUrl`/`statsUrl` are the live-events service endpoints; +// `token` is the project-scoped live-events JWT (minted server-side by +// Django as `team.live_events_token`). This object crosses renderer<->main +// ONLY — it must never be forwarded into the sandboxed iframe. +export const canvasLiveConnectionConfigSchema = z.object({ + eventsUrl: z.string(), + statsUrl: z.string(), + token: z.string(), +}); +export type CanvasLiveConnectionConfig = z.infer< + typeof canvasLiveConnectionConfigSchema +>; + // --------------------------------------------------------------------------- // Host <-> iframe postMessage protocol (Q10/Q11). The canvas runs in a // null-origin sandboxed iframe, so it CANNOT share JS objects with the host — @@ -199,6 +302,25 @@ export const hostToCanvasMessageSchema = z.discriminatedUnion("type", [ result: z.unknown().optional(), error: z.string().optional(), }), + // A single live event from the live-events SSE stream, fanned into the iframe. + // `subId` ties it to the canvas's subscribe request so a canvas can run + // several independent streams (e.g. one feed per event type). + z.object({ + channel: z.literal(CANVAS_CHANNEL), + type: z.literal("live-event"), + subId: z.string(), + event: liveEventSchema, + }), + // A terminal live-stream state change (auth failure, network close, or a + // deliberate `live-unsubscribe` ack). `status` is "closed" (no more events, + // do not retry) or "error" (the stream dropped; the canvas MAY resubscribe). + z.object({ + channel: z.literal(CANVAS_CHANNEL), + type: z.literal("live-stream-status"), + subId: z.string(), + status: z.enum(["closed", "error"]), + message: z.string().optional(), + }), ]); export type HostToCanvasMessage = z.infer; @@ -261,5 +383,23 @@ export const canvasToHostMessageSchema = z.discriminatedUnion("type", [ type: z.literal("open-external"), url: z.string().refine(isSafePostHogUrl), }), + // Subscribe to the live-events stream. The host mints/brokers the scoped JWT + // (never sent to the iframe) and streams matching events back as `live-event` + // frames for this `subId` until the canvas unsubscribes or the stream ends. + z.object({ + channel: z.literal(CANVAS_CHANNEL), + type: z.literal("live-subscribe"), + subId: z.string(), + params: canvasLiveEventsSubscribeInput, + }), + // Cancel a live-events subscription the canvas no longer needs (component + // unmount, filter change). The host closes the upstream SSE connection. + z.object({ + channel: z.literal(CANVAS_CHANNEL), + type: z.literal("live-unsubscribe"), + subId: z.string(), + }), + // NOTE: a live `liveStats` poll also rides the generic `data-request` frame + // above (method: "liveStats") — it's a one-shot read, not a stream. ]); export type CanvasToHostMessage = z.infer; diff --git a/packages/core/src/canvas/services.ts b/packages/core/src/canvas/services.ts index abae8e389a..3f64e7fadc 100644 --- a/packages/core/src/canvas/services.ts +++ b/packages/core/src/canvas/services.ts @@ -6,6 +6,8 @@ import type { CanvasCaptureResult, CanvasDataQueryInput, CanvasDataResult, + CanvasLiveConnectionConfig, + CanvasLiveStats, CanvasLoadInsightInput, FreeformVersion, } from "./freeformSchemas"; @@ -58,6 +60,21 @@ export interface ICanvasDataService { query(input: CanvasDataQueryInput): Promise; loadInsight(input: CanvasLoadInsightInput): Promise; capture(input: CanvasCaptureInput): Promise; + /** + * The brokered endpoint + JWT the live-events SSE stream authenticates with. + * The token NEVER enters the sandboxed iframe — the host renderer holds it + * only long enough to open the stream and fan events into the iframe as + * `live-event` postMessage frames. + */ + liveConnectionConfig(): Promise; + /** + * The project's realtime counters (`users_on_product`, `active_recordings`) + * from the live-events `/stats` endpoint — the same "users online" pill the + * app's Activity page shows. A one-shot poll (not the sliding-window count + * the canvas derives from its own event stream). + */ + liveStats(): Promise; + captureConfig(): Promise; captureConfig(): Promise; } diff --git a/packages/host-router/src/routers/canvas-data.router.ts b/packages/host-router/src/routers/canvas-data.router.ts index b1b5c213d1..be1397954f 100644 --- a/packages/host-router/src/routers/canvas-data.router.ts +++ b/packages/host-router/src/routers/canvas-data.router.ts @@ -2,6 +2,8 @@ import { canvasCaptureConfigSchema, canvasCaptureInput, canvasDataQueryInput, + canvasLiveConnectionConfigSchema, + canvasLiveStatsSchema, canvasLoadInsightInput, } from "@posthog/core/canvas/freeformSchemas"; import { CANVAS_DATA_SERVICE } from "@posthog/core/canvas/identifiers"; @@ -35,4 +37,21 @@ export const canvasDataRouter = router({ .get(CANVAS_DATA_SERVICE) .captureConfig(), ), + // The brokered live-events SSE endpoint + scoped JWT. The renderer holds + // this in memory only to open `/events` and fan events into the canvas + // iframe as postMessage frames; the token must never reach the sandbox. + liveConnectionConfig: publicProcedure + .output(canvasLiveConnectionConfigSchema) + .query(({ ctx }) => + ctx.container + .get(CANVAS_DATA_SERVICE) + .liveConnectionConfig(), + ), + // One-shot realtime counters (users online, active recordings) from the + // live-events `/stats` endpoint — a poll, distinct from the event stream. + liveStats: publicProcedure + .output(canvasLiveStatsSchema) + .query(({ ctx }) => + ctx.container.get(CANVAS_DATA_SERVICE).liveStats(), + ), }); diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 720d70e4ed..05be27ef6b 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -372,7 +372,7 @@ export { } from "./tool-meta"; export { TypedEventEmitter } from "./typed-event-emitter"; export { isSafeExternalUrl, isSafePostHogUrl } from "./url"; -export { getCloudUrlFromRegion } from "./urls"; +export { getCloudUrlFromRegion, getLiveEventsUrlFromRegion } from "./urls"; export { ALLOWED_VIDEO_MIME_TYPES, buildVideoDataUrl, diff --git a/packages/shared/src/urls.ts b/packages/shared/src/urls.ts index f41f6e58be..f9e1bc39df 100644 --- a/packages/shared/src/urls.ts +++ b/packages/shared/src/urls.ts @@ -10,3 +10,18 @@ export function getCloudUrlFromRegion(region: CloudRegion): string { return "http://localhost:8010"; } } + +// The live-events ("livestream") service origin for a region. The canvas host +// opens the SSE connection here — never the iframe — using the brokered +// live-events JWT. Dev falls back to the local livestream port (8666), matching +// the frontend's `liveEventsHostOrigin()` fallback. +export function getLiveEventsUrlFromRegion(region: CloudRegion): string { + switch (region) { + case "us": + return "https://live.us.posthog.com"; + case "eu": + return "https://live.eu.posthog.com"; + case "dev": + return "http://localhost:8666"; + } +} diff --git a/packages/ui/src/features/canvas/freeform/FreeformCanvas.tsx b/packages/ui/src/features/canvas/freeform/FreeformCanvas.tsx index afbce9eb32..4b888137a7 100644 --- a/packages/ui/src/features/canvas/freeform/FreeformCanvas.tsx +++ b/packages/ui/src/features/canvas/freeform/FreeformCanvas.tsx @@ -4,6 +4,7 @@ import { type CanvasToHostMessage, canvasToHostMessageSchema, type HostToCanvasMessage, + type LiveEvent as LiveEventPayload, } from "@posthog/core/canvas/freeformSchemas"; import { isSafePostHogUrl } from "@posthog/shared"; import { logger } from "@posthog/ui/shell/logger"; @@ -16,6 +17,7 @@ import { useMemo, useRef, } from "react"; +import { openLiveEventsStream } from "./freeformDataBridge"; import { buildSandboxDocument, type SandboxMode } from "./sandboxRuntime"; const log = logger.scope("freeform-canvas"); @@ -73,6 +75,17 @@ export function FreeformCanvas({ // shouldn't trigger re-renders. const readyRef = useRef(false); const lastExternalOpenRef = useRef(0); + // Active live-events SSE streams for this iframe, keyed by the canvas's subId. + // The renderer owns them (the canvas can't reach the network); we close them + // all on unmount/reload so a dead canvas never leaks a connection. + const liveStreamsRef = useRef void }>>(new Map()); + + const closeAllLiveStreams = useCallback(() => { + for (const handle of liveStreamsRef.current.values()) { + handle.close(); + } + liveStreamsRef.current.clear(); + }, []); // The document is keyed on mode + the analytics host (which the CSP must open // for posthog-js), not on code: code is injected via `init`, so changing it @@ -179,6 +192,39 @@ export function FreeformCanvas({ // msg.nav is already allowlist-validated by safeParse below. latest.current.onNavigate?.(msg.nav); break; + case "live-subscribe": { + // One active stream per subId: a resubscribe (e.g. filter change) + // replaces the previous connection. + liveStreamsRef.current.get(msg.subId)?.close(); + const handle = openLiveEventsStream( + msg.params, + // The bridge hands back opaque parsed JSON; the iframe validates + // inbound frames against `liveEventSchema`, so a malformed event is + // dropped inside the sandbox rather than crashing the canvas. + (event) => + post({ + channel: "posthog-canvas", + type: "live-event", + subId: msg.subId, + event: event as LiveEventPayload, + }), + (message) => + post({ + channel: "posthog-canvas", + type: "live-stream-status", + subId: msg.subId, + status: "error", + message, + }), + ); + liveStreamsRef.current.set(msg.subId, handle); + break; + } + case "live-unsubscribe": { + liveStreamsRef.current.get(msg.subId)?.close(); + liveStreamsRef.current.delete(msg.subId); + break; + } case "open-external": // Re-checks the schema's allowlist refine in case it ever drifts. if (!isSafePostHogUrl(msg.url)) { @@ -214,8 +260,11 @@ export function FreeformCanvas({ }; window.addEventListener("message", onMessage); - return () => window.removeEventListener("message", onMessage); - }, [postInit]); + return () => { + window.removeEventListener("message", onMessage); + closeAllLiveStreams(); + }; + }, [postInit, closeAllLiveStreams]); // Re-send init when the code / mode / analytics change, if the iframe is ready. // NB: reference code/mode/analytics DIRECTLY here (not via postInit, which diff --git a/packages/ui/src/features/canvas/freeform/freeformDataBridge.live.test.ts b/packages/ui/src/features/canvas/freeform/freeformDataBridge.live.test.ts new file mode 100644 index 0000000000..d6ae37617c --- /dev/null +++ b/packages/ui/src/features/canvas/freeform/freeformDataBridge.live.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import { buildLiveEventsUrl, parseSseChunk } from "./freeformDataBridge"; + +describe("buildLiveEventsUrl", () => { + const BASE = "https://live.us.posthog.com/events"; + + it("passes no params when all are absent", () => { + expect(buildLiveEventsUrl(BASE, {})).toBe(`${BASE}`); + }); + + it("adds eventType / distinctId / columns / properties like the app feed", () => { + const url = new URL( + buildLiveEventsUrl(BASE, { + eventType: "$pageview", + distinctId: "user@example.com", + columns: ["$current_url", "$browser"], + properties: [ + { key: "$browser", operator: "exact", value: "Chrome" }, + { key: "$geoip_country_code", operator: "is_set" }, + ], + }), + ); + expect(url.searchParams.get("eventType")).toBe("$pageview"); + expect(url.searchParams.get("distinctId")).toBe("user@example.com"); + expect(url.searchParams.get("columns")).toBe("$current_url,$browser"); + expect(JSON.parse(url.searchParams.get("properties") ?? "[]")).toEqual([ + { key: "$browser", operator: "exact", value: "Chrome" }, + { key: "$geoip_country_code", operator: "is_set" }, + ]); + }); + + it("omits empty columns and empty property arrays", () => { + const url = new URL( + buildLiveEventsUrl(BASE, { columns: [], properties: [] }), + ); + expect(url.searchParams.has("columns")).toBe(false); + expect(url.searchParams.has("properties")).toBe(false); + }); +}); + +describe("parseSseChunk", () => { + it("extracts data payloads and ignores comments/ids", () => { + const { payloads, tail } = parseSseChunk( + 'id: 12\n: keep-alive\ndata: {"a":1}\n\ndata: {"b":2}\n\n', + ); + expect(payloads).toEqual(['{"a":1}', '{"b":2}']); + expect(tail).toBe(""); + }); + + it("keeps an incomplete trailing frame for the next chunk", () => { + const first = parseSseChunk('data: {"a":1}\n\ndata: {"b"'); + expect(first.payloads).toEqual(['{"a":1}']); + expect(first.tail).toBe('data: {"b"'); + const second = parseSseChunk(`${first.tail}:2}\n\n`); + expect(second.payloads).toEqual(['{"b":2}']); + }); + + it("drops the [done] sentinel", () => { + const { payloads } = parseSseChunk("data: [done]\n\n"); + expect(payloads).toEqual([]); + }); +}); diff --git a/packages/ui/src/features/canvas/freeform/freeformDataBridge.ts b/packages/ui/src/features/canvas/freeform/freeformDataBridge.ts index 07edf5798f..87daebf68f 100644 --- a/packages/ui/src/features/canvas/freeform/freeformDataBridge.ts +++ b/packages/ui/src/features/canvas/freeform/freeformDataBridge.ts @@ -110,7 +110,152 @@ export async function handleFreeformDataRequest( case "run": // Named, server-stored insights land in Phase 3 (the live published tier). throw new Error("ph.run is not available yet (named queries: Phase 3)"); + case "liveStats": + // The project's realtime counters (users online, active recordings). + // A one-shot poll — never cached. The canvas also derives its own + // sliding-window "users online" count from its live-events stream; this + // is the server's authoritative counter. + return hostClient().canvasData.liveStats.query(); default: throw new Error(`Unknown data method "${method}"`); } } + +// --------------------------------------------------------------------------- +// Live events — a streaming counterpart to the request/response reads above. +// +// The sandboxed iframe cannot hold the project's `live_events_token` (a +// project-scoped JWT). So `ph.subscribeLiveEvents(params)` posts a +// `live-subscribe` frame here; the renderer asks the host (via tRPC) for the +// brokered connection config (eventsUrl + statsUrl + token), opens the SSE +// stream itself, and fans each parsed event back into the iframe over +// postMessage with `onEvent`. The token only ever lives in THIS renderer +// process — it never crosses the postMessage boundary into the sandbox, and +// it never reaches canvas code (the canvas only sees the event payloads). +// --------------------------------------------------------------------------- +export interface LiveEventsStreamHandle { + close: () => void; +} + +// Builds the `/events` query string from the canvas's subscribe params. +// Mirrors the app's live feed (`liveEventsLogic.tsx`): `eventType`, `distinctId`, +// `columns`, and AND-ed rich `properties` filters (JSON) — the livestream +// service compiles exactly the LIVE_EVENTS_SUPPORTED_OPERATORS allowlist. +export function buildLiveEventsUrl( + eventsUrl: string, + params: { + eventType?: string | null; + distinctId?: string | null; + columns?: string[] | null; + properties?: Array> | null; + }, +): string { + const url = new URL(eventsUrl); + if (params.eventType) url.searchParams.set("eventType", params.eventType); + if (params.distinctId) url.searchParams.set("distinctId", params.distinctId); + if (params.columns && params.columns.length > 0) { + url.searchParams.set("columns", params.columns.join(",")); + } + if (params.properties && params.properties.length > 0) { + url.searchParams.set("properties", JSON.stringify(params.properties)); + } + return url.toString(); +} + +/** + * Parse one chunk of SSE wire text into complete `data:`-line payloads and the + * leftover (incomplete) tail. Pure so it's unit-testable: the stream loop feeds + * it decoded chunks and keeps the tail for the next chunk. + */ +export function parseSseChunk(chunk: string): { + payloads: string[]; + tail: string; +} { + const text = chunk; + const payloads: string[] = []; + const lastNewline = text.lastIndexOf("\n"); + const complete = lastNewline === -1 ? "" : text.slice(0, lastNewline + 1); + const tail = lastNewline === -1 ? text : text.slice(lastNewline + 1); + for (const line of complete.split("\n")) { + const trimmed = line.trim(); + if (trimmed.startsWith("data:")) { + const payload = trimmed.slice(5).trim(); + if (payload && payload !== "[done]") { + payloads.push(payload); + } + } + } + return { payloads, tail }; +} + +/** + * Open a live-events SSE stream on behalf of a canvas and deliver each event + * to `onEvent`. The caller (FreeformCanvas) is responsible for invoking the + * returned `close()` when the canvas unsubscribes, unmounts, or its iframe + * reloads — the stream is a live connection, not a one-shot request. + * + * Returns a handle synchronously; the connection bootstrap (fetching config, + * opening the stream) is async and reports failure through `onError` so the + * canvas can resubscribe. + */ +export function openLiveEventsStream( + params: { + eventType?: string | null; + distinctId?: string | null; + columns?: string[] | null; + properties?: Array> | null; + }, + onEvent: (event: unknown) => void, + onError: (message: string) => void, +): LiveEventsStreamHandle { + const controller = new AbortController(); + + void (async () => { + try { + const config = await hostClient().canvasData.liveConnectionConfig.query(); + const url = buildLiveEventsUrl(config.eventsUrl, params); + const response = await fetch(url, { + method: "GET", + headers: { + Authorization: `Bearer ${config.token}`, + Accept: "text/event-stream", + }, + signal: controller.signal, + }); + if (!response.ok || !response.body) { + throw new Error(`Live events stream failed (${response.status})`); + } + + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let tail = ""; + // Parse the SSE wire format: lines of `data: ` separated by blank + // lines. We only consume the `data:` field (the service emits one JSON + // event per frame). + while (true) { + if (controller.signal.aborted) break; + const { done, value } = await reader.read(); + if (done) break; + const { payloads, tail: nextTail } = parseSseChunk( + tail + decoder.decode(value, { stream: true }), + ); + tail = nextTail; + for (const payload of payloads) { + try { + onEvent(JSON.parse(payload)); + } catch { + // Ignore a malformed event rather than killing the stream. + } + } + } + } catch (err) { + if (!controller.signal.aborted) { + onError(err instanceof Error ? err.message : String(err)); + } + } + })(); + + return { + close: () => controller.abort(), + }; +} diff --git a/packages/ui/src/features/canvas/freeform/sandboxRuntime.ts b/packages/ui/src/features/canvas/freeform/sandboxRuntime.ts index 8f4f73d27f..439b3dae26 100644 --- a/packages/ui/src/features/canvas/freeform/sandboxRuntime.ts +++ b/packages/ui/src/features/canvas/freeform/sandboxRuntime.ts @@ -215,6 +215,8 @@ export function buildSandboxDocument( // booted by init when analytics config is present; until then capture falls // back to the host-mediated path. let phClient = null; + let liveSubSeq = 0; + const liveSubs = new Map(); window.ph = { // Run a named, server-stored query (the only shape allowed in view mode). run: (name, params) => call("run", { name, params: params ?? {} }), @@ -257,6 +259,38 @@ export function buildSandboxDocument( toCanvas: (dashboardId) => post({ type: "navigate", nav: { target: "canvas", dashboardId } }), toNewCanvas: () => post({ type: "navigate", nav: { target: "new-canvas" } }), }, + // LIVE EVENTS — subscribe to the project's real-time event stream. The + // canvas never sees a token: the HOST holds the scoped JWT, opens the SSE + // to the live-events service, and pushes each matching event into this + // sandbox as a message. params.eventType filters to one event name + // (omit = all); params.properties is an AND-ed list of rich filters — + // ONLY these operators, exactly as the PostHog live-feed supports: + // exact | is_not | icontains | not_icontains | regex | not_regex | + // gt | gte | lt | lte | is_set | is_not_set. params.columns is a + // comma-whitelist of property keys; params.distinctId pins one actor. + // Returns an object you MUST close: const sub = ph.subscribeLiveEvents( + // params, (event) => {...}, (msg) => { stream ended/errored }); later + // sub.close(). Resubscribing with the same id replaces the old stream. + subscribeLiveEvents: (params, onEvent, onEnd) => { + const subId = "live-" + String(++liveSubSeq); + liveSubs.set(subId, { onEvent, onEnd }); + post({ type: "live-subscribe", subId, params: params ?? {} }); + return { + id: subId, + close: () => { + const sub = liveSubs.get(subId); + if (!sub) return; + liveSubs.delete(subId); + post({ type: "live-unsubscribe", subId }); + }, + }; + }, + // One-shot REALTIME COUNTERS for the project — { users_on_product, + // active_recordings } — the same "users online" pill the PostHog + // Activity page shows. Distinct from a stream: this is a single poll. + // Combine with the client-side sliding-window count you derive from + // ph.subscribeLiveEvents events for a live "users online" readout. + liveStats: () => call("liveStats", {}), }; // Keep target="_blank" anchors working without popup permission. Capture @@ -431,6 +465,35 @@ export function buildSandboxDocument( if (!p) return; pending.delete(d.id); d.ok ? p.resolve(d.result) : p.reject(new Error(d.error || "data error")); + } else if (d.type === "live-event") { + const sub = liveSubs.get(d.subId); + if (sub && sub.onEvent) { + try { + sub.onEvent(d.event); + } catch (err) { + reportError( + "live event handler failed: " + (err && err.message), + err && err.stack, + ); + } + } + } else if (d.type === "live-stream-status") { + const sub = liveSubs.get(d.subId); + if (sub) { + // The stream ended (closed) or errored — drop the subscription and + // let the canvas decide whether to resubscribe. + liveSubs.delete(d.subId); + if (sub.onEnd) { + try { + sub.onEnd(d.status, d.message); + } catch (err) { + reportError( + "live stream end handler failed: " + (err && err.message), + err && err.stack, + ); + } + } + } } });