diff --git a/apps/app/src/app/lib/den-telemetry.ts b/apps/app/src/app/lib/den-telemetry.ts new file mode 100644 index 000000000..de5b6547d --- /dev/null +++ b/apps/app/src/app/lib/den-telemetry.ts @@ -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 | 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 { + 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(); +} diff --git a/apps/app/src/react-app/domains/session/sync/actions-store.ts b/apps/app/src/react-app/domains/session/sync/actions-store.ts index bb6afc713..a587c4695 100644 --- a/apps/app/src/react-app/domains/session/sync/actions-store.ts +++ b/apps/app/src/react-app/domains/session/sync/actions-store.ts @@ -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 { @@ -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), diff --git a/ee/apps/den-api/src/app.ts b/ee/apps/den-api/src/app.ts index cb13ac0ff..39d118e87 100644 --- a/ee/apps/den-api/src/app.ts +++ b/ee/apps/den-api/src/app.ts @@ -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" @@ -113,6 +114,7 @@ registerVersionRoutes(app) registerWebhookRoutes(app) registerWorkerRoutes(app) registerMcpRoutes(app) +registerTelemetryRoutes(app) app.get( "/openapi.json", @@ -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." }, ], diff --git a/ee/apps/den-api/src/routes/telemetry/index.ts b/ee/apps/den-api/src/routes/telemetry/index.ts new file mode 100644 index 000000000..1518319c1 --- /dev/null +++ b/ee/apps/den-api/src/routes/telemetry/index.ts @@ -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 & Partial + +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(app: Hono) { + // ── 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`count(*)` }) + .from(MemberTable) + .where(eq(MemberTable.organizationId, orgId)), + db + .select({ count: sql`count(*)` }) + .from(InvitationTable) + .where(and(eq(InvitationTable.organizationId, orgId), eq(InvitationTable.status, "pending"))), + db + .select({ count: sql`count(distinct ${TelemetryEventTable.member_id})` }) + .from(TelemetryEventTable) + .where(and( + eq(TelemetryEventTable.org_id, orgId), + gte(TelemetryEventTable.event_timestamp, sevenDaysAgo), + )), + db + .select({ count: sql`count(distinct ${TelemetryEventTable.member_id})` }) + .from(TelemetryEventTable) + .where(and( + eq(TelemetryEventTable.org_id, orgId), + gte(TelemetryEventTable.event_timestamp, thirtyDaysAgo), + )), + db + .select({ + week: sql`FLOOR(DATEDIFF(${TelemetryEventTable.event_timestamp}, ${twelveWeeksAgo}) / 7)`, + count: sql`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, + }) + }, + ) +} diff --git a/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/dashboard-overview-screen.tsx b/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/dashboard-overview-screen.tsx index a96a9f4f5..9960c8dc7 100644 --- a/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/dashboard-overview-screen.tsx +++ b/ee/apps/den-web/app/(den)/o/[orgSlug]/dashboard/_components/dashboard-overview-screen.tsx @@ -1,178 +1,346 @@ "use client"; -import Link from "next/link"; +import { useState } from "react"; import { - Bot, - CreditCard, - Cpu, - KeyRound, - Monitor, + Activity, + ChevronRight, + Download, + Gauge, Users, } from "lucide-react"; -import { - getBackgroundAgentsRoute, - getApiKeysRoute, - getBillingRoute, - getCustomLlmProvidersRoute, - getOrgAccessFlags, - getMembersRoute, -} from "../../../../_lib/den-org"; +import { useQuery } from "@tanstack/react-query"; +import { PaperMeshGradient } from "@openwork/ui/react"; +import { requestJson } from "../../../../_lib/den-flow"; import { useDenFlow } from "../../../../_providers/den-flow-provider"; import { useOrgDashboard } from "../_providers/org-dashboard-provider"; +/* ── Types ── */ + +type AdoptionData = { + members: number; + pendingInvites: number; + activeUsers7d: number; + activeUsers30d: number; + weeklyTrend: number[]; +}; + +type CapRow = { + name: string; + seed: string; + invocations: string; + users: string; + successRate: string; + trend: number[]; +}; + +type TabId = "plugins" | "skills"; + +/* ── Data ── */ + +async function fetchAdoption(): Promise { + try { + const { response, payload } = await requestJson("/v1/telemetry/adoption", { method: "GET" }, 12000); + if (!response.ok || !payload || typeof payload !== "object") return null; + const p = payload as Record; + return { + members: typeof p.members === "number" ? p.members : 0, + pendingInvites: typeof p.pendingInvites === "number" ? p.pendingInvites : 0, + activeUsers7d: typeof p.activeMembers7d === "number" ? p.activeMembers7d : (typeof p.activeUsers7d === "number" ? p.activeUsers7d : 0), + activeUsers30d: typeof p.activeMembers30d === "number" ? p.activeMembers30d : (typeof p.activeUsers30d === "number" ? p.activeUsers30d : 0), + weeklyTrend: Array.isArray(p.weeklyTrend) ? p.weeklyTrend.map(Number) : [], + }; + } catch { + return null; + } +} + +/** Static fallback used when the telemetry endpoint is unavailable. */ +const FALLBACK_WEEKLY_TREND = [32, 41, 39, 52, 61, 68, 74, 70, 82, 88, 91, 96]; + +const pluginRows: CapRow[] = [ + { name: "Productivity", seed: "plg-productivity", invocations: "2.4K", users: "16", successRate: "98%", trend: [80, 90, 100, 110, 120, 130, 135, 140, 148, 155] }, + { name: "Enterprise Search", seed: "plg-enterprise-search", invocations: "1.1K", users: "12", successRate: "96%", trend: [30, 38, 45, 52, 60, 66, 72, 78, 82, 88] }, + { name: "Sales", seed: "plg-sales", invocations: "820", users: "9", successRate: "99%", trend: [20, 25, 30, 34, 40, 44, 48, 52, 55, 58] }, + { name: "Customer Support", seed: "plg-customer-support", invocations: "680", users: "9", successRate: "95%", trend: [14, 18, 22, 28, 32, 36, 40, 44, 48, 52] }, + { name: "Product Management", seed: "plg-product-management", invocations: "520", users: "7", successRate: "97%", trend: [8, 12, 15, 18, 22, 24, 28, 30, 32, 34] }, + { name: "Engineering", seed: "plg-engineering", invocations: "490", users: "6", successRate: "94%", trend: [10, 14, 16, 20, 22, 26, 30, 34, 36, 38] }, +]; + +const skillRows: CapRow[] = [ + { name: "Release readiness", seed: "sk-release", invocations: "1.2K", users: "14", successRate: "97%", trend: [40, 44, 50, 58, 66, 72, 80, 86, 92, 96] }, + { name: "Research brief", seed: "sk-research", invocations: "840", users: "11", successRate: "94%", trend: [20, 28, 34, 42, 50, 55, 60, 64, 68, 72] }, + { name: "Meeting prep", seed: "sk-meeting", invocations: "610", users: "9", successRate: "99%", trend: [15, 20, 24, 30, 34, 40, 44, 48, 51, 54] }, + { name: "Bug triage", seed: "sk-bug", invocations: "390", users: "6", successRate: "91%", trend: [10, 14, 16, 20, 22, 26, 30, 34, 36, 38] }, + { name: "Changelog draft", seed: "sk-changelog", invocations: "280", users: "5", successRate: "96%", trend: [5, 8, 10, 14, 16, 18, 22, 24, 26, 28] }, +]; + +/* ── Helpers ── */ + function getGreeting(name: string | null | undefined) { const hour = new Date().getHours(); - const greeting = hour < 12 ? "Good morning" : hour < 18 ? "Good afternoon" : "Good evening"; - const firstName = name?.trim().split(/\s+/)[0] ?? "there"; - return `${greeting}, ${firstName}`; + const g = hour < 12 ? "Good morning" : hour < 18 ? "Good afternoon" : "Good evening"; + return `${g}, ${name?.trim().split(/\s+/)[0] ?? "there"}`; } -export function DashboardOverviewScreen() { - const { orgSlug, activeOrg, orgContext } = useOrgDashboard(); - const { user } = useDenFlow(); - const access = getOrgAccessFlags( - orgContext?.currentMember.role ?? "member", - orgContext?.currentMember.isOwner ?? false, +function toneBg(tone: "violet" | "green" | "blue") { + switch (tone) { + case "violet": return "bg-[#EDE4FF]"; + case "green": return "bg-[#E3F3E3]"; + case "blue": return "bg-[#E4ECFB]"; + } +} + +function trendColor(trend: number[]): string { + if (trend.length < 3) return "#637291"; + const s = (trend[0] + trend[1] + trend[2]) / 3; + const e = trend.slice(-3).reduce((a, b) => a + b, 0) / 3; + if (e - s > 0.5) return "#18A34A"; + if (e - s < -0.5) return "#B43035"; + return "#637291"; +} + +/* ── Small components ── */ + +function Sparkline({ values, color, title }: { values: number[]; color: string; title?: string }) { + const w = 80, h = 20, pad = 2; + if (values.length === 0) return null; + const min = Math.min(...values), max = Math.max(...values), range = Math.max(max - min, 1); + const step = (w - pad * 2) / Math.max(values.length - 1, 1); + const pts = values.map((v, i) => { + const x = pad + i * step; + const y = pad + (h - pad * 2) * (1 - (v - min) / range); + return `${x.toFixed(1)},${y.toFixed(1)}`; + }); + const area = `M ${pad},${h - pad} ${pts.map((p) => `L ${p}`).join(" ")} L ${w - pad},${h - pad} Z`; + const line = `M ${pts.join(" L ")}`; + const last = pts[pts.length - 1].split(","); + return ( + + {title ? {title} : null} + + + + + ); +} + +function AreaChart({ values }: { values: number[] }) { + const w = 600, h = 120, padX = 24, padY = 12; + if (values.length === 0) return null; + const min = Math.min(...values), max = Math.max(...values), range = Math.max(max - min, 1); + const stepX = (w - padX * 2) / Math.max(values.length - 1, 1); + const pts = values.map((v, i) => { + const x = padX + i * stepX; + const y = padY + (h - padY * 2) * (1 - (v - min) / range); + return [x, y] as const; + }); + const line = pts.map((p, i) => `${i === 0 ? "M" : "L"} ${p[0].toFixed(1)},${p[1].toFixed(1)}`).join(" "); + const area = `${line} L ${pts[pts.length - 1][0].toFixed(1)},${h - padY} L ${padX},${h - padY} Z`; + const last = pts[pts.length - 1]; + return ( + + {[0, 0.25, 0.5, 0.75, 1].map((pct) => { + const y = padY + (h - padY * 2) * (1 - pct); + return ; + })} + + + + + ); +} + +function StatCard({ icon, title, value, sub, tone }: { + icon: React.ReactNode; title: string; value: string; sub?: string; tone: "violet" | "green" | "blue"; +}) { + return ( +
+
+
{icon}
+
+
{title}
+
{value}
+ {sub ?
{sub}
: null} +
+
+
+ ); +} + +function EnterpriseBadge() { + return ( + + Enterprise only + + ); +} + +function GradientTile({ seed }: { seed: string }) { + return ( +
+ +
); +} - const quickActions = [ - { - label: "Members", - icon: Users, - href: getMembersRoute(orgSlug), - tint: "bg-cyan-50 text-cyan-600 group-hover:bg-cyan-100", - }, - ...(access.canManageApiKeys - ? [{ - label: "API Keys", - icon: KeyRound, - href: getApiKeysRoute(orgSlug), - tint: "bg-emerald-50 text-emerald-600 group-hover:bg-emerald-100", - }] - : []), - { - label: "Shared Workspace", - icon: Bot, - href: getBackgroundAgentsRoute(orgSlug), - tint: "bg-orange-50 text-orange-500 group-hover:bg-orange-100", - }, - { - label: "LLM Providers", - icon: Cpu, - href: getCustomLlmProvidersRoute(orgSlug), - tint: "bg-lime-50 text-lime-600 group-hover:bg-lime-100", - }, - { - label: "Billing", - icon: CreditCard, - href: getBillingRoute(orgSlug), - tint: "bg-gray-100 text-gray-600 group-hover:bg-gray-200", - }, - { - label: "Desktop app", - icon: Monitor, - href: "https://openworklabs.com/download", - external: true, - tint: "bg-fuchsia-50 text-fuchsia-600 group-hover:bg-fuchsia-100", - }, +function TabBar({ active, onChange }: { active: TabId; onChange: (t: TabId) => void }) { + const tabs: { id: TabId; label: string }[] = [ + { id: "plugins", label: "Plugins" }, + { id: "skills", label: "Skills" }, ]; + return ( +
+ {tabs.map((t) => { + const sel = t.id === active; + return ( + + ); + })} +
+ ); +} +function CapTable({ rows, kind }: { rows: CapRow[]; kind: "plugin" | "skill" }) { return ( -
-

- {activeOrg?.name ?? "OpenWork Cloud"} -

-

- {getGreeting(user?.name)} -

- -
- {quickActions.map((action) => { - const content = ( - <> -
- -
- - {action.label} - - - ); - - const className = - "group flex min-h-[116px] flex-col items-center gap-3 rounded-2xl border border-gray-100 bg-white p-5 transition-all hover:border-gray-200 hover:shadow-[0_2px_8px_-4px_rgba(0,0,0,0.08)]"; - - if (action.external) { - return ( - - {content} - - ); - } - - return ( - - {content} - - ); - })} +
+
+
{kind === "plugin" ? "Plugin" : "Skill"}
+
Invocations
+
Users
+
Success
+
Trend
+ {rows.map((row) => ( +
+
+ + {row.name} +
+
{row.invocations}
+
{row.users}
+
{row.successRate}
+
+
+ ))} +
+ ); +} + +/* ── Main screen ── */ + +export function DashboardOverviewScreen() { + const { activeOrg, orgContext } = useOrgDashboard(); + const { user } = useDenFlow(); + const [tab, setTab] = useState("plugins"); + const [showEnterprisePreview, setShowEnterprisePreview] = useState(true); + + const { data: adoption } = useQuery({ + queryKey: ["telemetry", "adoption"], + queryFn: fetchAdoption, + }); -
-
-

- Cloud workspace control -

-

- Launch shared workspaces, manage access, and connect teammates to hosted OpenWork workers from this dashboard. -

- i.status === "pending").length; + const activeUsers7d = adoption?.activeUsers7d ?? 0; + const weeklyTrendData = adoption?.weeklyTrend ?? FALLBACK_WEEKLY_TREND; + + return ( +
+ + {/* Breadcrumb */} +
+ {activeOrg?.name ?? "OpenWork Cloud"} + + Usage Insights +
+ + {/* Greeting */} +

{getGreeting(user?.name)}

+

Live workspace adoption now, advanced usage insights for enterprise deployments.

+ + {/* Live org data */} +
+ } title="OpenWork users" value={`${members}`} sub="Current workspace members" tone="violet" /> + } title="Pending invites" value={`${pending}`} sub="Awaiting activation" tone="blue" /> +
+ + {/* Enterprise analytics preview */} +
+
+
+
+ Usage insights + +
+

+ Optional telemetry for customer-owned analytics. Hidden by default in non-enterprise rollouts. +

+
+
-
-
-

Desktop app

-

- Run locally for free, keep your data on your machine, and move to shared web workflows when your team is ready. -

- - - Use desktop only - + {!showEnterprisePreview ? ( +
+ Enterprise usage analytics are hidden. Live workspace membership remains visible above.
+ ) : ( +
+
+ } title="Active this week" value={`${activeUsers7d}`} sub={adoption ? "From telemetry" : "Preview signal"} tone="green" /> + } title="Tasks completed" value="1,284" sub="Preview signal" tone="blue" /> +
-
-

Workspace snapshot

-
-
- Members - {orgContext?.members.length ?? 0} +
+
+
+ Weekly active users + Trend preview +
+
+ +
-
- Pending invites - - {(orgContext?.invitations ?? []).filter((invitation) => invitation.status === "pending").length} - + +
+ Telemetry settings +
+
CollectionUsage
+
IdentityAnonymized
+
Retention90 days
+
Prompt dataNever collected
+
+ +
+ +
+
+ {tab === "plugins" ? : null} + {tab === "skills" ? : null} +
+ )} +
+ + {/* Download CTA */} +
+
+

Download OpenWork

+

Run locally for free. Keep data on your machine and move to shared workflows when ready.

+ + Download +
); diff --git a/ee/apps/den-web/next-env.d.ts b/ee/apps/den-web/next-env.d.ts index 9edff1c7c..c4b7818fb 100644 --- a/ee/apps/den-web/next-env.d.ts +++ b/ee/apps/den-web/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -import "./.next/types/routes.d.ts"; +import "./.next/dev/types/routes.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/ee/packages/den-db/package.json b/ee/packages/den-db/package.json index f5f67c37f..af6bbc662 100644 --- a/ee/packages/den-db/package.json +++ b/ee/packages/den-db/package.json @@ -46,6 +46,11 @@ "development": "./src/schema/system.ts", "default": "./dist/schema/system.js" }, + "./schema/telemetry": { + "types": "./src/schema/telemetry.ts", + "development": "./src/schema/telemetry.ts", + "default": "./dist/schema/telemetry.js" + }, "./drizzle": { "types": "./src/drizzle.ts", "development": "./src/drizzle.ts", diff --git a/ee/packages/den-db/src/drizzle.ts b/ee/packages/den-db/src/drizzle.ts index e2e9600f0..a6f9021f8 100644 --- a/ee/packages/den-db/src/drizzle.ts +++ b/ee/packages/den-db/src/drizzle.ts @@ -1 +1 @@ -export { and, asc, desc, eq, gt, inArray, isNotNull, isNull, or, sql } from "drizzle-orm" +export { and, asc, desc, eq, gt, gte, inArray, isNotNull, isNull, or, sql } from "drizzle-orm" diff --git a/ee/packages/den-db/src/schema/index.ts b/ee/packages/den-db/src/schema/index.ts index cd6375fff..c17847898 100644 --- a/ee/packages/den-db/src/schema/index.ts +++ b/ee/packages/den-db/src/schema/index.ts @@ -6,3 +6,4 @@ export * from "./sharables/skills" export * from "./teams" export * from "./workers" export * from "./system" +export * from "./telemetry" diff --git a/ee/packages/den-db/src/schema/telemetry.ts b/ee/packages/den-db/src/schema/telemetry.ts new file mode 100644 index 000000000..d81cecadc --- /dev/null +++ b/ee/packages/den-db/src/schema/telemetry.ts @@ -0,0 +1,20 @@ +import { index, mysqlTable, varchar, timestamp } from "drizzle-orm/mysql-core" +import { denTypeIdColumn } from "../columns" + +export const TelemetryEventType = ["session.active"] as const + +export const TelemetryEventTable = mysqlTable( + "telemetry_event", + { + id: denTypeIdColumn("telemetryEvent", "id").notNull().primaryKey(), + org_id: denTypeIdColumn("organization", "org_id").notNull(), + member_id: denTypeIdColumn("member", "member_id").notNull(), + event_type: varchar("event_type", { length: 64 }).notNull(), + event_timestamp: timestamp("event_timestamp", { fsp: 3 }).notNull(), + created_at: timestamp("created_at", { fsp: 3 }).notNull().defaultNow(), + }, + (table) => [ + index("telemetry_event_org_id_type_ts").on(table.org_id, table.event_type, table.event_timestamp), + index("telemetry_event_org_id_member_id").on(table.org_id, table.member_id), + ], +) diff --git a/ee/packages/den-db/tsup.config.ts b/ee/packages/den-db/tsup.config.ts index 8652a82ce..4994e8775 100644 --- a/ee/packages/den-db/tsup.config.ts +++ b/ee/packages/den-db/tsup.config.ts @@ -10,6 +10,7 @@ export default defineConfig({ "schema/teams": "src/schema/teams.ts", "schema/workers": "src/schema/workers.ts", "schema/system": "src/schema/system.ts", + "schema/telemetry": "src/schema/telemetry.ts", drizzle: "src/drizzle.ts", }, format: ["esm"], diff --git a/ee/packages/utils/src/typeid.ts b/ee/packages/utils/src/typeid.ts index 919fcd562..4eb5b3dd3 100644 --- a/ee/packages/utils/src/typeid.ts +++ b/ee/packages/utils/src/typeid.ts @@ -58,6 +58,7 @@ export const idTypesMapNameToPrefix = { workerToken: "wkt", workerBundle: "wkb", auditEvent: "aev", + telemetryEvent: "tev", } as const export const denTypeIdPrefixes = idTypesMapNameToPrefix