diff --git a/apps/server/src/routes/rpc/interceptors/__tests__/tracking.test.ts b/apps/server/src/routes/rpc/interceptors/__tests__/tracking.test.ts index 5ddd847a..6ebe54be 100644 --- a/apps/server/src/routes/rpc/interceptors/__tests__/tracking.test.ts +++ b/apps/server/src/routes/rpc/interceptors/__tests__/tracking.test.ts @@ -116,12 +116,12 @@ describe("trackingInterceptor", () => { }); }); - test("extracts additional props from message", async () => { + test("extracts additional props from nested create message", async () => { const interceptor = trackingInterceptor(); const req = createMockRequest( "openstatus.monitor.v1.MonitorService", "CreateHTTPMonitor", - { url: "https://example.com", jobType: "http", name: "my-monitor" }, + { monitor: { url: "https://example.com", name: "my-monitor" } }, ); const next = mockNext({}); @@ -134,6 +134,24 @@ describe("trackingInterceptor", () => { }); }); + test("maps icmp uri to url and stamps jobType", async () => { + const interceptor = trackingInterceptor(); + const req = createMockRequest( + "openstatus.monitor.v1.MonitorService", + "CreateICMPMonitor", + { monitor: { name: "ping", uri: "example.com" } }, + ); + const next = mockNext({}); + + await interceptor(next)(req as never); + await Promise.resolve(); + + expect(mockTrack).toHaveBeenCalledWith({ + ...Events.CreateMonitor, + additionalProps: { url: "example.com", jobType: "icmp" }, + }); + }); + test("silently skips unmapped methods", async () => { const interceptor = trackingInterceptor(); const req = createMockRequest( diff --git a/apps/server/src/routes/rpc/interceptors/tracking.ts b/apps/server/src/routes/rpc/interceptors/tracking.ts index 4923c141..4ae4ff35 100644 --- a/apps/server/src/routes/rpc/interceptors/tracking.ts +++ b/apps/server/src/routes/rpc/interceptors/tracking.ts @@ -14,8 +14,23 @@ const logger = getLogger("api-server"); type RpcEventMapping = { event: EventProps; eventProps?: string[]; + normalizeInput?: (message: unknown) => Record; }; +// Create*Monitor requests nest the config under `monitor`, so top-level +// extraction yields nothing; ICMP names its target `uri` and none of them +// carries jobType on the wire. +function monitorCreateInput(jobType: string) { + return (message: unknown): Record => { + if (typeof message !== "object" || message === null) return {}; + const { monitor } = message as { + monitor?: Record | undefined; + }; + if (!monitor) return {}; + return { ...monitor, url: monitor.uri ?? monitor.url, jobType }; + }; +} + /** * Mapping from "ServiceTypeName/MethodName" to OpenPanel event + optional props. * Keys use PascalCase method names matching DescMethod.name (the proto source name). @@ -26,18 +41,22 @@ export const RPC_EVENT_MAP: Record = { "openstatus.monitor.v1.MonitorService/CreateHTTPMonitor": { event: Events.CreateMonitor, eventProps: ["url", "jobType"], + normalizeInput: monitorCreateInput("http"), }, "openstatus.monitor.v1.MonitorService/CreateTCPMonitor": { event: Events.CreateMonitor, eventProps: ["url", "jobType"], + normalizeInput: monitorCreateInput("tcp"), }, "openstatus.monitor.v1.MonitorService/CreateDNSMonitor": { event: Events.CreateMonitor, eventProps: ["url", "jobType"], + normalizeInput: monitorCreateInput("dns"), }, "openstatus.monitor.v1.MonitorService/CreateICMPMonitor": { event: Events.CreateMonitor, eventProps: ["url", "jobType"], + normalizeInput: monitorCreateInput("icmp"), }, "openstatus.monitor.v1.MonitorService/UpdateHTTPMonitor": { event: Events.UpdateMonitor, @@ -145,7 +164,8 @@ export function trackingInterceptor(): Interceptor { return response; } - const additionalProps = parseInputToProps(req.message, mapping.eventProps); + const input = mapping.normalizeInput?.(req.message) ?? req.message; + const additionalProps = parseInputToProps(input, mapping.eventProps); setupAnalytics({ userId: `api_${rpcCtx.workspace.id}`,