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
23 changes: 23 additions & 0 deletions apps/daemon/src/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,13 +234,20 @@ export interface AnalyticsService {
properties?: Record<string, unknown>;
insertId?: string;
}): Promise<void>;
identifyGroup(args: {
context: AnalyticsContext;
groupType: 'workspace';
groupKey: string;
properties: Record<string, unknown>;
}): Promise<void>;
shutdown(): Promise<void>;
}

const NOOP_SERVICE: AnalyticsService = {
capture: async () => undefined,
captureSafety: async () => undefined,
mergeAnonymousPerson: async () => undefined,
identifyGroup: async () => undefined,
shutdown: async () => undefined,
};

Expand Down Expand Up @@ -414,6 +421,22 @@ export function createAnalyticsService(args: {
// Attribution merge failures must not block app startup or consent.
}
},
identifyGroup: async ({ context, groupType, groupKey, properties }) => {
try {
const appCfg = await readAppConfig(args.dataDir);
if (appCfg.telemetry?.metrics !== true) return;
const cleanProperties = cleanPosthogPersonProperties(properties);
if (!groupKey || Object.keys(cleanProperties).length === 0) return;
client.groupIdentify({
groupType,
groupKey,
distinctId: context.deviceId,
properties: cleanProperties,
});
} catch {
// Group updates are best-effort and must never affect product reads.
}
},
shutdown: async () => {
try {
await client.shutdown();
Expand Down
5 changes: 5 additions & 0 deletions apps/daemon/src/collab/workspace-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,8 @@ export function resolveWorkspaceSettingsUrl(
// explicit user action that may change B's Active Workspace (the client
// itself never PUTs it). A bare /settings link would depend on whatever
// workspace another device left active, so every console link pins the id.
// The low-cardinality source marker lets Vela attribute the resulting
// Workspace management outcomes without carrying user-entered values.
if (typeof explicit === 'string' && explicit.trim()) {
return withWorkspaceDeepLink(explicit.trim(), workspaceId);
}
Expand All @@ -213,6 +215,9 @@ function withWorkspaceDeepLink(url: string, workspaceId: string): string {
if (!parsed.searchParams.get('workspaceId') && workspaceId.trim()) {
parsed.searchParams.set('workspaceId', workspaceId.trim());
}
if (!parsed.searchParams.get('source')) {
parsed.searchParams.set('source', 'open_design');
}
return parsed.toString();
} catch {
return url;
Expand Down
33 changes: 32 additions & 1 deletion apps/daemon/src/routes/collab-context.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { Express, Response } from 'express';
import type { Express, Request, Response } from 'express';
import type {
CollabCloudMemberDirectoryEntry,
CollabCloudMembersResponse,
Expand Down Expand Up @@ -175,10 +175,32 @@ export interface RegisterCollabContextRoutesDeps {
send: (event: string, data: unknown, id?: string | number | null) => boolean;
};
workspaceEventSinks?: WorkspaceEventSinksByWorkspace;
/** Best-effort PostHog group update; never affects the route response. */
observeWorkspace?: (
req: Request,
context: WorkspaceCollabContext,
properties?: Record<string, unknown>,
) => Promise<void> | void;
}

const ASSIGNABLE_ROLES = new Set<WorkspaceInviteRole>(['admin', 'member']);

function workspaceGroupProperties(
context: WorkspaceCollabContext,
): Record<string, unknown> {
const planId = context.planId?.trim().toLowerCase();
return {
workspace_type: context.workspaceType,
workspace_lifecycle: context.lifecycleState,
billing_state: context.billingState,
plan_bucket: !planId || planId === 'free' ? 'free' : 'paid',
provider_mode: context.providerMode,
seat_limit: context.seatSummary.seatLimit,
member_count: context.seatSummary.usedSeats,
seat_state: context.seatSummary.isSeatFull ? 'full' : 'available',
};
}

/**
* Normalize an invite-create request body into validated { email, role } items.
* Accepts either the canonical `{ invites: [...] }` batch shape or a single
Expand Down Expand Up @@ -285,6 +307,13 @@ export function registerCollabContextRoutes(app: Express, deps: RegisterCollabCo
// its first exact-scope read. A refresh outage must not turn a successfully
// consumed, non-repeatable continuation into an HTTP failure.
await deps.refreshWorkspaceDirectoryAfterMutation?.().catch(() => undefined);
if (outcome.context) {
void deps.observeWorkspace?.(
req,
outcome.context,
workspaceGroupProperties(outcome.context),
);
}
return res.json({ context: outcome.context, workspaceMemberId: outcome.workspaceMemberId });
});

Expand Down Expand Up @@ -347,6 +376,7 @@ export function registerCollabContextRoutes(app: Express, deps: RegisterCollabCo
? enriched
: verified.context;
const body: WorkspaceContextResponse = { context };
void deps.observeWorkspace?.(req, context, workspaceGroupProperties(context));
res.json(body);
});

Expand Down Expand Up @@ -502,6 +532,7 @@ export function registerCollabContextRoutes(app: Express, deps: RegisterCollabCo
// await them — a slow upstream must not delay the tab-local selection.
deps.onWorkspaceSwitched?.(workspaceId);
const body: WorkspaceActiveResponse = { activeWorkspaceId: workspaceId, context: resolved };
void deps.observeWorkspace?.(req, resolved, workspaceGroupProperties(resolved));
res.json(body);
});

Expand Down
26 changes: 26 additions & 0 deletions apps/daemon/src/routes/project/analytics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
/**
* Group properties derived from an authoritative Workspace project-list read.
* Only canonical views update the group: filtered subsets must never overwrite
* the Workspace's total project count.
*/
export function workspaceProjectGroupCountProperties(input: {
view: string;
owner: string;
visibility: string;
projectCount: number;
}): Record<string, number> | null {
if (
(input.view === 'all' || input.view === 'recent')
&& input.owner === 'all'
&& input.visibility === 'all'
) {
return { project_count: input.projectCount };
}
if (input.view === 'drafts') {
return { draft_project_count: input.projectCount };
}
if (input.view === 'team') {
return { team_project_count: input.projectCount };
}
return null;
}
49 changes: 49 additions & 0 deletions apps/daemon/src/routes/project/comments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ export type ProjectCommentWorkspaceContextResolution =
};

export interface RegisterProjectCommentRoutesDeps extends RouteDeps<'db' | 'projectStore' | 'conversations'> {
/** Optional in focused CRUD fixtures; production supplies request-scoped analytics. */
telemetry?: RouteDeps<'telemetry'>['telemetry'];
/**
* Gate POST (create/edit)/PATCH status/DELETE on the caller's WORKSPACE
* identity, before the author-identity logic below ever runs (spec 04 §10
Expand Down Expand Up @@ -454,6 +456,53 @@ export function registerProjectCommentRoutes(app: Express, ctx: RegisterProjectC
}
return saved;
})();
// Only a genuinely new, successfully persisted comment is counted.
// Edits reuse this POST route with an id and must not inflate creation.
if (comment && !requestedId) {
const localBinding = typeof getWorkspaceProjectByProjectId === 'function'
? getWorkspaceProjectByProjectId(db, req.params.id) as
| { createdByWorkspaceMemberId?: string | null }
| undefined
: undefined;
let ownerMemberId = localBinding?.createdByWorkspaceMemberId ?? null;
if (!ownerMemberId && ctx.resolveProjectOwnerMemberId) {
ownerMemberId = await ctx.resolveProjectOwnerMemberId(
req.params.id,
workspaceContext,
).catch(() => null);
}
const targetProjectRelation =
authorMemberId && ownerMemberId
? authorMemberId === ownerMemberId
? 'self'
: 'other'
: 'unknown';
const planId = workspaceContext?.planId?.trim().toLowerCase();
void ctx.telemetry?.captureProductEvent?.(
req,
'project_comment_create_result',
{
page_name: 'artifact',
area: 'comments',
result: 'success',
target_project_relation: targetProjectRelation,
comment_level: 'top_level',
...(workspaceContext
? {
workspace_key: workspaceContext.workspaceId,
workspace_type: workspaceContext.workspaceType,
workspace_role: workspaceContext.role,
workspace_lifecycle: workspaceContext.lifecycleState,
billing_state: workspaceContext.billingState,
plan_bucket: !planId || planId === 'free' ? 'free' : 'paid',
provider_mode: workspaceContext.providerMode,
seat_state: workspaceContext.seatSummary.isSeatFull ? 'full' : 'available',
$groups: { workspace: workspaceContext.workspaceId },
}
: {}),
},
);
}
res.json({ comment });
} catch (err: any) {
res.status(400).json({ error: String(err?.message || err) });
Expand Down
15 changes: 15 additions & 0 deletions apps/daemon/src/routes/project/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ import {
import { auditDesignSystemPackage } from '../../tools-connectors-cli.js';
import { parseOrchestratorWorkspace } from '../../workspace-contract.js';
import { registerProjectConversationRoutes } from './conversations.js';
import { workspaceProjectGroupCountProperties } from './analytics.js';
import type { ProjectCommentWorkspaceContextResolution } from './comments.js';
import {
projectResourceIdFor,
Expand Down Expand Up @@ -1637,6 +1638,7 @@ function buildDesignSystemCopyPendingPrompt(input: {

export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDeps) {
const { db, design } = ctx;
const projectTelemetry = ctx.telemetry;
const { sendApiError, createSseResponse } = ctx.http;
const { DESIGN_SYSTEMS_DIR, PROJECTS_DIR, SKILLS_DIR, BRANDS_DIR, USER_DESIGN_SYSTEMS_DIR } = ctx.paths;
const { readAppConfig, writeAppConfig } = ctx.appConfig;
Expand Down Expand Up @@ -2941,6 +2943,19 @@ export function registerProjectRoutes(app: Express, ctx: RegisterProjectRoutesDe
if (owner === 'others' && createdByCurrentMember) return false;
return true;
});
const groupCountProperties = workspaceProjectGroupCountProperties({
view,
owner,
visibility,
projectCount: projects.length,
});
if (groupCountProperties) {
void projectTelemetry.identifyWorkspaceGroup?.(
req,
ctx.workspaceId,
groupCountProperties,
);
}
/** @type {import('@open-design/contracts').WorkspaceProjectsResponse} */
const body = { projects };
res.json(body);
Expand Down
12 changes: 12 additions & 0 deletions apps/daemon/src/server-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,18 @@ export interface TelemetryDeps {
resolveRunProjectKindForAnalytics: (...args: any[]) => any;
runArtifactBaselines: any;
runRetryEventsForAnalytics: (...args: any[]) => any;
/** Product-result capture for request-scoped, consented analytics. */
captureProductEvent?: (
req: any,
eventName: string,
properties: Record<string, unknown>,
) => Promise<void> | void;
/** Update one PostHog Workspace group from an authoritative read. */
identifyWorkspaceGroup?: (
req: any,
workspaceId: string,
properties: Record<string, unknown>,
) => Promise<void> | void;
}

export interface ServerContext {
Expand Down
40 changes: 38 additions & 2 deletions apps/daemon/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -468,7 +468,7 @@ import {
import { reportRunCompletedFromDaemon } from './langfuse-bridge.js';
import { reconcileDurableRunTerminals } from './runtimes/run-terminal-reconciliation.js';
import { buildPromptStackTelemetry } from './prompt-telemetry.js';
import { readAnalyticsContext } from './analytics.js';
import { newInsertId, readAnalyticsContext, type AnalyticsService } from './analytics.js';
import {
agentIdToTracking,
modelIdForTracking,
Expand Down Expand Up @@ -4607,6 +4607,7 @@ export async function startServer({
freshAuthority: true,
}).catch(() => undefined);
};
let workspaceAnalyticsService: AnalyticsService | null = null;
registerCollabContextRoutes(app, {
workspaceContext: collab.workspaceContext,
activeWorkspace,
Expand Down Expand Up @@ -4634,6 +4635,17 @@ export async function startServer({
// registers/deregisters its sink here; the poller below feeds them.
createSseResponse,
workspaceEventSinks,
observeWorkspace: async (req, context, properties) => {
const service = workspaceAnalyticsService;
const analyticsContext = readAnalyticsContext(req);
if (!service || !analyticsContext) return;
await service.identifyGroup({
context: analyticsContext,
groupType: 'workspace',
groupKey: context.workspaceId,
properties: properties ?? {},
});
},
});
// Reconnect/source-gap recovery belongs to the Workspace whose upstream
// subscription observed the gap. Keep one signature state per Workspace so
Expand Down Expand Up @@ -6144,6 +6156,7 @@ export async function startServer({
writeAppConfig,
});
const { analyticsService } = telemetry;
workspaceAnalyticsService = analyticsService;
const design = {
runs: createChatRunService({
createSseResponse,
Expand Down Expand Up @@ -6792,7 +6805,30 @@ export async function startServer({
},
events: projectEventDeps,
ids: idDeps,
telemetry: { reportFinalizedMessage },
telemetry: {
reportFinalizedMessage,
captureProductEvent: async (req, eventName, properties) => {
const analyticsContext = readAnalyticsContext(req);
if (!analyticsContext) return;
await analyticsService.capture({
eventName,
context: analyticsContext,
appVersion: telemetry.getCachedAppVersion()?.version ?? '0.0.0',
properties,
insertId: newInsertId(),
});
},
identifyWorkspaceGroup: async (req, workspaceId, properties) => {
const analyticsContext = readAnalyticsContext(req);
if (!analyticsContext) return;
await analyticsService.identifyGroup({
context: analyticsContext,
groupType: 'workspace',
groupKey: workspaceId,
properties,
});
},
},
appConfig: appConfigDeps,
agents: agentDeps,
validation: validationDeps,
Expand Down
Loading
Loading