Skip to content
Merged
125 changes: 125 additions & 0 deletions apps/app/src/app/lib/den-telemetry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
/**
* Den telemetry reporter.
*
* Activates lazily when the user is signed into Den.
* Sends lightweight usage signals to POST /v1/telemetry/ingest.
* Fire-and-forget: no retries, no queue, no local storage.
* If the request fails, the error is swallowed silently.
*
* The server extracts org_id and user_id from the auth session.
* The client never sends prompt contents, code, or file paths.
*/

import { isDesktopRuntime } from "../utils";
import { type DenSettings, readDenSettings, resolveDenBaseUrls } from "./den";

const INGEST_PATH = "/v1/telemetry/ingest";
const INGEST_TIMEOUT_MS = 5_000;

type TelemetryEvent = {
type: string;
timestamp: string;
};

let pendingEvents: TelemetryEvent[] = [];
let flushTimer: ReturnType<typeof setTimeout> | null = null;
const FLUSH_INTERVAL_MS = 10_000;
const MAX_BATCH_SIZE = 50;

function getResolvedIngestUrl(settings: DenSettings): string | null {
if (!settings.authToken) return null;

const baseUrls = resolveDenBaseUrls({
baseUrl: settings.baseUrl,
apiBaseUrl: settings.apiBaseUrl,
});

return `${baseUrls.apiBaseUrl}${INGEST_PATH}`;
}

async function flushEvents(): Promise<void> {
if (pendingEvents.length === 0) return;

const settings = readDenSettings();
if (!settings.authToken) {
pendingEvents = [];
return;
}

const url = getResolvedIngestUrl(settings);
if (!url) {
pendingEvents = [];
return;
}

const batch = pendingEvents.splice(0, MAX_BATCH_SIZE);

try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), INGEST_TIMEOUT_MS);

const fetchFn = isDesktopRuntime() ? globalThis.fetch : globalThis.fetch;

await fetchFn(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${settings.authToken}`,
},
body: JSON.stringify({ events: batch }),
signal: controller.signal,
credentials: "include",
});

clearTimeout(timeout);
} catch {
// Swallow silently -- telemetry should never affect UX
}
}

function scheduleFlush(): void {
if (flushTimer) return;
flushTimer = setTimeout(() => {
flushTimer = null;
void flushEvents();
}, FLUSH_INTERVAL_MS);
}

/**
* Track a telemetry event. The event is batched and flushed periodically.
* If the user is not signed into Den, the event is silently dropped.
*/
export function trackTelemetryEvent(type: string): void {
const settings = readDenSettings();
if (!settings.authToken) return;

pendingEvents.push({
type,
timestamp: new Date().toISOString(),
});

if (pendingEvents.length >= MAX_BATCH_SIZE) {
void flushEvents();
} else {
scheduleFlush();
}
}

/**
* Track that the user started an OpenCode session.
* This is the primary "are people actually using the app" signal.
*/
export function trackSessionActive(): void {
trackTelemetryEvent("session.active");
}

/**
* Flush any pending events immediately. Call on sign-out or app close.
*/
export function flushTelemetry(): void {
if (flushTimer) {
clearTimeout(flushTimer);
flushTimer = null;
}
void flushEvents();
}
2 changes: 2 additions & 0 deletions apps/app/src/react-app/domains/session/sync/actions-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
shellInSession,
unrevertSession,
} from "../../../../app/lib/opencode-session";
import { trackSessionActive } from "../../../../app/lib/den-telemetry";
import { finishPerf, perfNow, recordPerfLog } from "../../../../app/lib/perf-log";
import { toSessionTransportDirectory } from "../../../../app/lib/session-scope";
import type {
Expand Down Expand Up @@ -392,6 +393,7 @@ export function createSessionActionsStore(options: {
mark("session:create:start");
rawResult = await c.session.create({ directory });
mark("session:create:ok");
trackSessionActive();
} catch (createErr) {
mark("session:create:error", {
error: createErr instanceof Error ? createErr.message : safeStringify(createErr),
Expand Down
3 changes: 3 additions & 0 deletions ee/apps/den-api/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { registerVersionRoutes } from "./routes/version/index.js"
import { registerWebhookRoutes } from "./routes/webhooks/index.js"
import { registerWorkerRoutes } from "./routes/workers/index.js"
import { registerMcpRoutes } from "./mcp/index.js"
import { registerTelemetryRoutes } from "./routes/telemetry/index.js"
import type { AuthContextVariables } from "./session.js"
import { sessionMiddleware } from "./session.js"

Expand Down Expand Up @@ -113,6 +114,7 @@ registerVersionRoutes(app)
registerWebhookRoutes(app)
registerWorkerRoutes(app)
registerMcpRoutes(app)
registerTelemetryRoutes(app)

app.get(
"/openapi.json",
Expand Down Expand Up @@ -159,6 +161,7 @@ app.get(
{ name: "Workers", description: "Worker lifecycle, billing, and runtime routes." },
{ name: "Worker Runtime", description: "Worker runtime inspection and upgrade routes." },
{ name: "Worker Activity", description: "Worker heartbeat and activity reporting routes." },
{ name: "Telemetry", description: "Telemetry event ingestion and adoption analytics." },
{ name: "Admin", description: "Administrative reporting routes." },
{ name: "Users", description: "Current user and membership routes." },
],
Expand Down
158 changes: 158 additions & 0 deletions ee/apps/den-api/src/routes/telemetry/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import { and, eq, gte, sql } from "@openwork-ee/den-db/drizzle"
import { TelemetryEventTable, MemberTable, InvitationTable } from "@openwork-ee/den-db/schema"
import { createDenTypeId } from "@openwork-ee/utils/typeid"
import type { Hono } from "hono"
import { describeRoute } from "hono-openapi"
import { z } from "zod"
import { db } from "../../db.js"
import { requireUserMiddleware, resolveUserOrganizationsMiddleware, resolveOrganizationContextMiddleware, jsonValidator } from "../../middleware/index.js"
import { invalidRequestSchema, jsonResponse, unauthorizedSchema, emptyResponse } from "../../openapi.js"
import type { AuthContextVariables } from "../../session.js"
import type { UserOrganizationsContext, OrganizationContextVariables } from "../../middleware/index.js"

type TelemetryRouteVariables = AuthContextVariables & Partial<UserOrganizationsContext> & Partial<OrganizationContextVariables>

const ingestBodySchema = z.object({
type: z.string().min(1).max(64),
timestamp: z.string().datetime(),
})

const ingestBatchSchema = z.object({
events: z.array(ingestBodySchema).min(1).max(50),
})

const adoptionResponseSchema = z.object({
members: z.number(),
pendingInvites: z.number(),
activeMembers7d: z.number(),
activeMembers30d: z.number(),
weeklyTrend: z.array(z.number()),
}).meta({ ref: "TelemetryAdoptionResponse" })

export function registerTelemetryRoutes<T extends { Variables: TelemetryRouteVariables }>(app: Hono<T>) {
// ── POST /v1/telemetry/ingest ─────────────────────────────────────────────
app.post(
"/v1/telemetry/ingest",
describeRoute({
tags: ["Telemetry"],
summary: "Ingest telemetry events",
description: "Receives a batch of telemetry events from the OpenWork app. Auth provides org and member identity. Always returns 204.",
responses: {
204: emptyResponse("Events accepted."),
400: jsonResponse("Invalid event payload.", invalidRequestSchema),
401: jsonResponse("Caller must be signed in.", unauthorizedSchema),
},
}),
requireUserMiddleware,
resolveUserOrganizationsMiddleware,
resolveOrganizationContextMiddleware,
jsonValidator(ingestBatchSchema),
async (c) => {
const orgContext = c.get("organizationContext")
const orgId = c.get("activeOrganizationId")

if (!orgContext || !orgId) {
return c.body(null, 204)
}

const memberId = orgContext.currentMember.id
const body = c.req.valid("json")

try {
const rows = body.events.map((event) => ({
id: createDenTypeId("telemetryEvent"),
org_id: orgId,
member_id: memberId,
event_type: event.type,
event_timestamp: new Date(event.timestamp),
}))

if (rows.length > 0) {
await db.insert(TelemetryEventTable).values(rows)
}
} catch {
// Swallow errors -- telemetry should never break the app
}

return c.body(null, 204)
},
)

// ── GET /v1/telemetry/adoption ────────────────────────────────────────────
app.get(
"/v1/telemetry/adoption",
describeRoute({
tags: ["Telemetry"],
summary: "Get adoption metrics",
description: "Returns org adoption metrics: member count, pending invites, active members in 7d and 30d windows, and a 12-week weekly active member trend.",
responses: {
200: jsonResponse("Adoption metrics returned.", adoptionResponseSchema),
401: jsonResponse("Caller must be signed in.", unauthorizedSchema),
},
}),
requireUserMiddleware,
resolveUserOrganizationsMiddleware,
async (c) => {
const orgId = c.get("activeOrganizationId")

if (!orgId) {
return c.json({ members: 0, pendingInvites: 0, activeMembers7d: 0, activeMembers30d: 0, weeklyTrend: [] })
}

const now = new Date()
const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000)
const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000)
const twelveWeeksAgo = new Date(now.getTime() - 12 * 7 * 24 * 60 * 60 * 1000)

const [memberRows, inviteRows, active7dRows, active30dRows, weeklyRows] = await Promise.all([
db
.select({ count: sql<number>`count(*)` })
.from(MemberTable)
.where(eq(MemberTable.organizationId, orgId)),
db
.select({ count: sql<number>`count(*)` })
.from(InvitationTable)
.where(and(eq(InvitationTable.organizationId, orgId), eq(InvitationTable.status, "pending"))),
db
.select({ count: sql<number>`count(distinct ${TelemetryEventTable.member_id})` })
.from(TelemetryEventTable)
.where(and(
eq(TelemetryEventTable.org_id, orgId),
gte(TelemetryEventTable.event_timestamp, sevenDaysAgo),
)),
db
.select({ count: sql<number>`count(distinct ${TelemetryEventTable.member_id})` })
.from(TelemetryEventTable)
.where(and(
eq(TelemetryEventTable.org_id, orgId),
gte(TelemetryEventTable.event_timestamp, thirtyDaysAgo),
)),
db
.select({
week: sql<number>`FLOOR(DATEDIFF(${TelemetryEventTable.event_timestamp}, ${twelveWeeksAgo}) / 7)`,
count: sql<number>`count(distinct ${TelemetryEventTable.member_id})`,
})
.from(TelemetryEventTable)
.where(and(
eq(TelemetryEventTable.org_id, orgId),
gte(TelemetryEventTable.event_timestamp, twelveWeeksAgo),
))
.groupBy(sql`FLOOR(DATEDIFF(${TelemetryEventTable.event_timestamp}, ${twelveWeeksAgo}) / 7)`)
.orderBy(sql`FLOOR(DATEDIFF(${TelemetryEventTable.event_timestamp}, ${twelveWeeksAgo}) / 7)`),
])

const weeklyTrend = Array.from({ length: 12 }, (_, i) => {
const row = weeklyRows.find((r) => Number(r.week) === i)
return row ? Number(row.count) : 0
})

return c.json({
members: Number(memberRows[0]?.count ?? 0),
pendingInvites: Number(inviteRows[0]?.count ?? 0),
activeMembers7d: Number(active7dRows[0]?.count ?? 0),
activeMembers30d: Number(active30dRows[0]?.count ?? 0),
weeklyTrend,
})
},
)
}
Loading
Loading