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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion ee/apps/den-api/src/entitlements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ export type OrganizationPlan = {
grandfatheredAt?: string
}

export const ENTITLEMENT_KEYS = ["sso", "desktopPolicies", "orgControls"] as const
export const ENTITLEMENT_KEYS = ["sso", "desktopPolicies", "orgControls", "analytics"] as const
export type EntitlementKey = (typeof ENTITLEMENT_KEYS)[number]

export type OrganizationEntitlements = Record<EntitlementKey, boolean>
Expand All @@ -27,6 +27,7 @@ const ENTITLEMENT_FEATURE_LABELS: Record<EntitlementKey, string> = {
sso: "SSO / SAML",
desktopPolicies: "Desktop policies",
orgControls: "Enforced SSO and desktop version controls",
analytics: "Usage analytics",
}

type MetadataInput = Record<string, unknown> | string | null | undefined
Expand Down Expand Up @@ -86,6 +87,7 @@ export function getOrganizationEntitlements(
sso: entitled,
desktopPolicies: entitled,
orgControls: entitled,
analytics: entitled,
}
}

Expand Down
202 changes: 192 additions & 10 deletions ee/apps/den-api/src/routes/telemetry/index.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,28 @@
import { and, eq, gte, isNull, sql } from "@openwork-ee/den-db/drizzle"
import { TelemetryEventTable, MemberTable, InvitationTable } from "@openwork-ee/den-db/schema"
import { TelemetryEventTable, TelemetryEventType, 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 { checkEntitlement } from "../../entitlements.js"
import { requireUserMiddleware, resolveUserOrganizationsMiddleware, resolveOrganizationContextMiddleware, jsonValidator } from "../../middleware/index.js"
import { invalidRequestSchema, jsonResponse, unauthorizedSchema, emptyResponse } from "../../openapi.js"
import { enterprisePlanRequiredSchema, 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 allowedEventTypes = new Set<string>(TelemetryEventType)
const allowedSources = new Set(["app", "worker"])

const ingestBodySchema = z.object({
type: z.string().min(1).max(64),
timestamp: z.string().datetime(),
source: z.string().max(32).optional(),
sessionId: z.string().max(128).optional(),
durationMs: z.number().int().min(0).max(86_400_000).optional(),
success: z.boolean().optional(),
})

const ingestBatchSchema = z.object({
Expand All @@ -29,14 +37,74 @@ const adoptionResponseSchema = z.object({
weeklyTrend: z.array(z.number()),
}).meta({ ref: "TelemetryAdoptionResponse" })

const analyticsWeekSchema = z.object({
weekStart: z.string(),
activeMembers: z.number(),
sessions: z.number(),
tasksCompleted: z.number(),
tasksFailed: z.number(),
})

const analyticsResponseSchema = z.object({
members: z.number(),
pendingInvites: z.number(),
activeMembers7d: z.number(),
activeMembers30d: z.number(),
sessions7d: z.number(),
sessions30d: z.number(),
tasksCompleted7d: z.number(),
tasksFailed7d: z.number(),
tasksCompleted30d: z.number(),
tasksFailed30d: z.number(),
avgTaskDurationMs30d: z.number().nullable(),
weekly: z.array(analyticsWeekSchema),
}).meta({ ref: "TelemetryAnalyticsResponse" })

const ANALYTICS_WEEKS = 12

type WindowMetrics = {
activeMembers: number
sessions: number
tasksCompleted: number
tasksFailed: number
avgTaskDurationMs: number | null
}

type TelemetryOrgId = (typeof TelemetryEventTable.$inferSelect)["org_id"]

async function loadWindowMetrics(orgId: TelemetryOrgId, since: Date): Promise<WindowMetrics> {
const rows = await db
.select({
activeMembers: sql<number>`count(distinct ${TelemetryEventTable.member_id})`,
sessions: sql<number>`count(distinct ${TelemetryEventTable.session_id})`,
tasksCompleted: sql<number>`coalesce(sum(${TelemetryEventTable.event_type} = 'task.completed'), 0)`,
tasksFailed: sql<number>`coalesce(sum(${TelemetryEventTable.event_type} = 'task.failed'), 0)`,
avgTaskDurationMs: sql<number | null>`avg(case when ${TelemetryEventTable.event_type} = 'task.completed' then ${TelemetryEventTable.duration_ms} end)`,
})
.from(TelemetryEventTable)
.where(and(
eq(TelemetryEventTable.org_id, orgId),
gte(TelemetryEventTable.event_timestamp, since),
))

const row = rows[0]
return {
activeMembers: Number(row?.activeMembers ?? 0),
sessions: Number(row?.sessions ?? 0),
tasksCompleted: Number(row?.tasksCompleted ?? 0),
tasksFailed: Number(row?.tasksFailed ?? 0),
avgTaskDurationMs: row?.avgTaskDurationMs == null ? null : Math.round(Number(row.avgTaskDurationMs)),
}
}

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.",
description: "Receives a batch of telemetry events from the OpenWork app or workers. Auth provides org and member identity. Unknown event types and disallowed fields are dropped. Always returns 204.",
responses: {
204: emptyResponse("Events accepted."),
400: jsonResponse("Invalid event payload.", invalidRequestSchema),
Expand All @@ -59,13 +127,19 @@ export function registerTelemetryRoutes<T extends { Variables: TelemetryRouteVar
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),
}))
const rows = body.events
.filter((event) => allowedEventTypes.has(event.type))
.map((event) => ({
id: createDenTypeId("telemetryEvent"),
org_id: orgId,
member_id: memberId,
event_type: event.type,
event_timestamp: new Date(event.timestamp),
source: event.source && allowedSources.has(event.source) ? event.source : null,
session_id: event.sessionId ?? null,
duration_ms: event.durationMs ?? null,
success: event.success ?? null,
}))

if (rows.length > 0) {
await db.insert(TelemetryEventTable).values(rows)
Expand Down Expand Up @@ -155,4 +229,112 @@ export function registerTelemetryRoutes<T extends { Variables: TelemetryRouteVar
})
},
)

// ── GET /v1/telemetry/analytics ───────────────────────────────────────────
app.get(
"/v1/telemetry/analytics",
describeRoute({
tags: ["Telemetry"],
summary: "Get usage analytics",
description: "Returns Layer 1 (who is using AI) and Layer 2 (how often) analytics for the active org: member counts, active members, session and task volume in 7d/30d windows, average task duration, and a 12-week trend of active members, sessions, and tasks.",
responses: {
200: jsonResponse("Analytics returned.", analyticsResponseSchema),
401: jsonResponse("Caller must be signed in.", unauthorizedSchema),
402: jsonResponse("Usage analytics requires an Enterprise plan.", enterprisePlanRequiredSchema),
},
}),
requireUserMiddleware,
resolveUserOrganizationsMiddleware,
resolveOrganizationContextMiddleware,
async (c) => {
const orgId = c.get("activeOrganizationId")

const empty = {
members: 0,
pendingInvites: 0,
activeMembers7d: 0,
activeMembers30d: 0,
sessions7d: 0,
sessions30d: 0,
tasksCompleted7d: 0,
tasksFailed7d: 0,
tasksCompleted30d: 0,
tasksFailed30d: 0,
avgTaskDurationMs30d: null,
weekly: [],
}

if (!orgId) {
return c.json(empty)
}

// Same enterprise gate as SSO / desktop policies (see entitlements.ts):
// collection (/ingest) stays open; only the analytics view is gated.
const orgContext = c.get("organizationContext")
const entitlement = checkEntitlement(orgContext?.organization.metadata ?? null, "analytics")
if (!entitlement.ok) {
return c.json(entitlement.response, entitlement.status)
}

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 trendStart = new Date(now.getTime() - ANALYTICS_WEEKS * 7 * 24 * 60 * 60 * 1000)

const [memberRows, inviteRows, window7d, window30d, weeklyRows] = await Promise.all([
db
.select({ count: sql<number>`count(*)` })
.from(MemberTable)
.where(and(eq(MemberTable.organizationId, orgId), isNull(MemberTable.removedAt))),
db
.select({ count: sql<number>`count(*)` })
.from(InvitationTable)
.where(and(eq(InvitationTable.organizationId, orgId), eq(InvitationTable.status, "pending"))),
loadWindowMetrics(orgId, sevenDaysAgo),
loadWindowMetrics(orgId, thirtyDaysAgo),
db
.select({
week: sql<number>`FLOOR(DATEDIFF(${TelemetryEventTable.event_timestamp}, ${trendStart}) / 7)`,
activeMembers: sql<number>`count(distinct ${TelemetryEventTable.member_id})`,
sessions: sql<number>`count(distinct ${TelemetryEventTable.session_id})`,
tasksCompleted: sql<number>`coalesce(sum(${TelemetryEventTable.event_type} = 'task.completed'), 0)`,
tasksFailed: sql<number>`coalesce(sum(${TelemetryEventTable.event_type} = 'task.failed'), 0)`,
})
.from(TelemetryEventTable)
.where(and(
eq(TelemetryEventTable.org_id, orgId),
gte(TelemetryEventTable.event_timestamp, trendStart),
))
.groupBy(sql`FLOOR(DATEDIFF(${TelemetryEventTable.event_timestamp}, ${trendStart}) / 7)`)
.orderBy(sql`FLOOR(DATEDIFF(${TelemetryEventTable.event_timestamp}, ${trendStart}) / 7)`),
])

const weekly = Array.from({ length: ANALYTICS_WEEKS }, (_, i) => {
const weekStart = new Date(trendStart.getTime() + i * 7 * 24 * 60 * 60 * 1000)
const row = weeklyRows.find((r) => Number(r.week) === i)
return {
weekStart: weekStart.toISOString().slice(0, 10),
activeMembers: Number(row?.activeMembers ?? 0),
sessions: Number(row?.sessions ?? 0),
tasksCompleted: Number(row?.tasksCompleted ?? 0),
tasksFailed: Number(row?.tasksFailed ?? 0),
}
})

return c.json({
members: Number(memberRows[0]?.count ?? 0),
pendingInvites: Number(inviteRows[0]?.count ?? 0),
activeMembers7d: window7d.activeMembers,
activeMembers30d: window30d.activeMembers,
sessions7d: window7d.sessions,
sessions30d: window30d.sessions,
tasksCompleted7d: window7d.tasksCompleted,
tasksFailed7d: window7d.tasksFailed,
tasksCompleted30d: window30d.tasksCompleted,
tasksFailed30d: window30d.tasksFailed,
avgTaskDurationMs30d: window30d.avgTaskDurationMs,
weekly,
})
},
)
}
17 changes: 17 additions & 0 deletions ee/apps/den-api/test/entitlements.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ test("entitlements are all granted when gating is disabled", () => {
sso: true,
desktopPolicies: true,
orgControls: true,
analytics: true,
})
})

Expand All @@ -41,16 +42,19 @@ test("entitlements require the enterprise tier when gating is enabled", () => {
sso: false,
desktopPolicies: false,
orgControls: false,
analytics: false,
})
expect(entitlements.getOrganizationEntitlements({ plan: { tier: "team" } }, { gatingEnabled: true })).toEqual({
sso: false,
desktopPolicies: false,
orgControls: false,
analytics: false,
})
expect(entitlements.getOrganizationEntitlements({ plan: { tier: "enterprise", source: "manual" } }, { gatingEnabled: true })).toEqual({
sso: true,
desktopPolicies: true,
orgControls: true,
analytics: true,
})
})

Expand All @@ -60,6 +64,7 @@ test("grandfathered organizations keep full entitlements when gating is enabled"
sso: true,
desktopPolicies: true,
orgControls: true,
analytics: true,
})
})

Expand All @@ -78,3 +83,15 @@ test("checkEntitlement passes for entitled organizations", () => {
expect(entitlements.checkEntitlement({ plan: { tier: "enterprise" } }, "desktopPolicies", { gatingEnabled: true })).toEqual({ ok: true })
expect(entitlements.checkEntitlement(null, "desktopPolicies", { gatingEnabled: false })).toEqual({ ok: true })
})

test("usage analytics follows the same enterprise gate", () => {
const denied = entitlements.checkEntitlement(null, "analytics", { gatingEnabled: true })
expect(denied.ok).toBe(false)
if (!denied.ok) {
expect(denied.status).toBe(402)
expect(denied.response.feature).toBe("analytics")
expect(denied.response.message).toContain("Usage analytics")
}
expect(entitlements.checkEntitlement({ plan: { tier: "enterprise" } }, "analytics", { gatingEnabled: true })).toEqual({ ok: true })
expect(entitlements.checkEntitlement(null, "analytics", { gatingEnabled: false })).toEqual({ ok: true })
})
4 changes: 4 additions & 0 deletions ee/packages/den-db/drizzle/0024_violet_rawhide_kid.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
ALTER TABLE `telemetry_event` ADD `source` varchar(32);--> statement-breakpoint
ALTER TABLE `telemetry_event` ADD `session_id` varchar(128);--> statement-breakpoint
ALTER TABLE `telemetry_event` ADD `duration_ms` int;--> statement-breakpoint
ALTER TABLE `telemetry_event` ADD `success` boolean;
Loading
Loading