-
-
Notifications
You must be signed in to change notification settings - Fork 1.3k
Datadog Implementation #1990
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Datadog Implementation #1990
Changes from all commits
11df938
fa657aa
dc1a70f
191f550
6102738
efebf19
84ffed8
79dc812
c9689d3
16e8da6
2cc510c
a546e16
8d592e2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,27 @@ | ||
| import { createTRPCClient, httpBatchLink } from '@trpc/client'; | ||
| import type { AppRouter } from '@zero/server/trpc'; | ||
| import superjson from 'superjson'; | ||
|
|
||
| const getUrl = () => import.meta.env.VITE_PUBLIC_BACKEND_URL + '/api/trpc'; | ||
|
|
||
| export const api = createTRPCClient<AppRouter>({ | ||
| links: [ | ||
| httpBatchLink({ | ||
| maxItems: 1, | ||
adamghaida marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| url: getUrl(), | ||
| transformer: superjson, | ||
| fetch: (url, options) => | ||
| fetch(url, { ...options, credentials: 'include' }).then((res) => { | ||
| if (typeof window !== 'undefined') { | ||
| const currentPath = new URL(window.location.href).pathname; | ||
| const redirectPath = res.headers.get('X-Zero-Redirect'); | ||
| if (!!redirectPath && redirectPath !== currentPath) { | ||
| window.location.href = redirectPath; | ||
| res.headers.delete('X-Zero-Redirect'); | ||
adamghaida marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
| } | ||
| return res; | ||
| }), | ||
| }), | ||
| ], | ||
| }); | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,206 @@ | ||
| import { client, v2 } from '@datadog/datadog-api-client'; | ||
| import type { TRPCCallLog } from '../types/logging'; | ||
| import type { ZeroEnv } from '../env'; | ||
|
|
||
| export class DatadogService { | ||
| private apiInstance: v2.LogsApi; | ||
| private apiKey: string; | ||
| private appKey: string; | ||
| private site: string; | ||
|
|
||
| constructor(env?: ZeroEnv) { | ||
| // Runtime validation for required Datadog credentials | ||
| if (!env?.DD_API_KEY || env.DD_API_KEY.trim() === '') { | ||
| throw new Error('DD_API_KEY environment variable is required and cannot be empty for Datadog service'); | ||
| } | ||
|
|
||
| if (!env?.DD_APP_KEY || env.DD_APP_KEY.trim() === '') { | ||
| throw new Error('DD_APP_KEY environment variable is required and cannot be empty for Datadog service'); | ||
| } | ||
|
|
||
| const configuration = client.createConfiguration({ | ||
| authMethods: { | ||
| apiKeyAuth: env.DD_API_KEY, | ||
| appKeyAuth: env.DD_APP_KEY, | ||
| }, | ||
| }); | ||
|
|
||
| // Set the site for the configuration (defaults to datadoghq.com if not provided) | ||
| const ddSite = env?.DD_SITE || 'datadoghq.com'; | ||
| configuration.setServerVariables({ site: ddSite }); | ||
|
|
||
| this.apiInstance = new v2.LogsApi(configuration); | ||
| this.apiKey = env.DD_API_KEY; | ||
| this.appKey = env.DD_APP_KEY; | ||
| this.site = ddSite; | ||
| } | ||
|
|
||
| private generateId(): string { | ||
| return crypto.randomUUID().replace(/-/g, ''); | ||
| } | ||
|
|
||
| // Check if a procedure is logging-related to avoid recursive logging | ||
| private isLoggingProcedure(procedure: string): boolean { | ||
| const loggingProcedures = [ | ||
| 'logging.getSessionStats', | ||
| 'logging.clearSession', | ||
| 'logging.getSessionState', | ||
| 'logging.exportToDatadog', | ||
| ]; | ||
| return loggingProcedures.includes(procedure); | ||
| } | ||
|
|
||
| async logSingleCall(sessionId: string, userId: string, log: TRPCCallLog): Promise<void> { | ||
| // Skip logging-related procedures to avoid recursive logging | ||
| if (this.isLoggingProcedure(log.procedure)) { | ||
| return; | ||
| } | ||
|
|
||
| try { | ||
| const traceId = this.generateId(); | ||
| const spanId = this.generateId(); | ||
|
|
||
| const performanceCategory = log.duration < 100 ? 'fast' : log.duration < 500 ? 'normal' : 'slow'; | ||
| const hasError = !!log.error; | ||
| const logLevel = hasError ? 'error' : performanceCategory === 'slow' ? 'warn' : 'info'; | ||
|
|
||
| // Parse user agent for device/browser info | ||
| const parseUserAgent = (userAgent?: string) => { | ||
| if (!userAgent) return {}; | ||
|
|
||
| const browsers = { | ||
| chrome: /Chrome\/([0-9.]+)/i, | ||
| firefox: /Firefox\/([0-9.]+)/i, | ||
| safari: /Safari\/([0-9.]+)/i, | ||
| edge: /Edg\/([0-9.]+)/i, | ||
adamghaida marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| }; | ||
|
|
||
| const os = { | ||
| windows: /Windows NT ([0-9.]+)/i, | ||
| macos: /Mac OS X ([0-9_.]+)/i, | ||
| linux: /Linux/i, | ||
| android: /Android ([0-9.]+)/i, | ||
| ios: /OS ([0-9_]+)/i, | ||
| }; | ||
|
|
||
| const devices = { | ||
| mobile: /Mobile|Android|iPhone/i, | ||
| tablet: /iPad|Tablet/i, | ||
| desktop: /Windows|Mac|Linux/i, | ||
| }; | ||
|
|
||
| let browser = 'unknown', browserVersion = '', operatingSystem = 'unknown', osVersion = '', deviceType = 'unknown'; | ||
|
|
||
| // Detect browser | ||
| for (const [name, regex] of Object.entries(browsers)) { | ||
| const match = userAgent.match(regex); | ||
| if (match) { | ||
| browser = name; | ||
| browserVersion = match[1]; | ||
| break; | ||
| } | ||
| } | ||
|
|
||
| // Detect OS | ||
| for (const [name, regex] of Object.entries(os)) { | ||
| const match = userAgent.match(regex); | ||
| if (match) { | ||
| operatingSystem = name; | ||
| osVersion = match[1]?.replace(/_/g, '.') || ''; | ||
| break; | ||
| } | ||
| } | ||
|
|
||
| // Detect device type | ||
| for (const [type, regex] of Object.entries(devices)) { | ||
| if (regex.test(userAgent)) { | ||
| deviceType = type; | ||
| break; | ||
| } | ||
| } | ||
|
|
||
adamghaida marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| return { | ||
| browser, | ||
| browser_version: browserVersion, | ||
| operating_system: operatingSystem, | ||
| os_version: osVersion, | ||
| device_type: deviceType, | ||
| user_agent: userAgent, | ||
| }; | ||
| }; | ||
|
|
||
| const deviceInfo = parseUserAgent(log.metadata?.userAgent); | ||
|
|
||
| const logEntry = { | ||
| message: `${logLevel.toUpperCase()}: TRPC call: [${log.procedure}] (${log.duration}ms)`, | ||
| status: logLevel, | ||
| service: 'zero-mail-app', | ||
| ddsource: 'trpc-logging', | ||
| ddtags: `session:${sessionId},user:${userId},procedure:${log.procedure},duration:${log.duration}ms,has_error:${hasError},performance:${performanceCategory},browser:${deviceInfo.browser},device:${deviceInfo.device_type}`, | ||
| hostname: 'cloudflare-worker', | ||
| timestamp: log.timestamp, | ||
|
|
||
| // Trace correlation fields | ||
| dd: { | ||
| trace_id: traceId, | ||
| span_id: spanId, | ||
| }, | ||
|
|
||
| additionalProperties: { | ||
adamghaida marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| // Core call data | ||
| call_id: log.id, | ||
| procedure: log.procedure, | ||
| duration: log.duration, | ||
| performance_category: performanceCategory, | ||
| trpc_method: log.metadata?.method || 'unknown', | ||
|
|
||
| // Session context | ||
| session_id: sessionId, | ||
| user_id: userId, | ||
|
|
||
| // HTTP context | ||
| http_method: 'POST', | ||
| http_url: `/api/trpc/${log.procedure}`, | ||
| client_ip: log.metadata?.ip, | ||
| referer: log.metadata?.referer, | ||
| origin: log.metadata?.origin, | ||
| accept_language: log.metadata?.acceptLanguage, | ||
| accept_encoding: log.metadata?.acceptEncoding, | ||
| request_id: log.metadata?.requestId, | ||
|
|
||
| // Device and browser information | ||
| ...deviceInfo, | ||
|
|
||
| // Error handling | ||
| has_error: hasError, | ||
| ...(log.error && { | ||
| error_message: log.error, | ||
| error_type: 'trpc_error', | ||
| }), | ||
|
|
||
| // Full request/response data | ||
| request_payload: log.input, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Logging the full request payload may leak sensitive data (tokens, PII) and can significantly increase log size/cost. Consider redacting or whitelisting fields before sending to Datadog. Prompt for AI agents
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. worth review |
||
| ...(log.output && { | ||
| response_payload: log.output, | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Logging the full response payload may leak sensitive data (PII or secrets) and inflate log size. Prefer redacting or only including minimal metadata/errors. Prompt for AI agents |
||
| }), | ||
|
|
||
| // Performance metrics | ||
| timing: { | ||
| start_time: log.metadata?.startTime || log.timestamp, | ||
| end_time: log.metadata?.endTime || (log.timestamp + log.duration), | ||
| duration_ms: log.duration, | ||
| performance_category: performanceCategory, | ||
| }, | ||
|
|
||
| // Complete request trace with all spans (from log.trace) | ||
| trace: log.trace, | ||
| } | ||
| }; | ||
|
|
||
| await this.apiInstance.submitLog({ body: [logEntry] }); | ||
|
|
||
| } catch (error) { | ||
| console.error('β Failed to log TRPC call to Datadog:', error); | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,117 @@ | ||
| import type { TRPCCallLog, LoggingState, SessionStats } from '../types/logging'; | ||
| import { DatadogService } from './datadog-service'; | ||
| import type { ZeroEnv } from '../env'; | ||
|
|
||
| // In-memory session storage for stats | ||
| // In a production environment, you might want to use a distributed cache like Redis | ||
| const sessionStats = new Map<string, LoggingState>(); | ||
|
|
||
| export class LoggingService { | ||
| private datadogService: DatadogService; | ||
|
|
||
| constructor(env: ZeroEnv) { | ||
| this.datadogService = new DatadogService(env); | ||
| } | ||
|
|
||
| async logCall(callData: Omit<TRPCCallLog, 'id' | 'timestamp'>): Promise<void> { | ||
| const log: TRPCCallLog = { | ||
| ...callData, | ||
| id: crypto.randomUUID(), | ||
| timestamp: Date.now(), | ||
| }; | ||
|
|
||
| // Immediately export to Datadog | ||
| try { | ||
| await this.datadogService.logSingleCall( | ||
| callData.sessionId, | ||
| callData.userId, | ||
| log | ||
| ); | ||
| } catch (error) { | ||
| console.error('β Failed to log TRPC call to Datadog:', error); | ||
| } | ||
|
|
||
| // Update in-memory session stats | ||
| this.updateSessionStats(callData.sessionId, callData.userId, log); | ||
| } | ||
|
|
||
| private updateSessionStats(sessionId: string, userId: string, log: TRPCCallLog): void { | ||
| let currentState = sessionStats.get(sessionId); | ||
|
|
||
| if (!currentState) { | ||
| currentState = { | ||
| sessionId, | ||
| userId, | ||
| startedAt: Date.now(), | ||
| lastActivity: Date.now(), | ||
| totalCalls: 0, | ||
| totalErrors: 0, | ||
| totalDuration: 0, | ||
| }; | ||
| } | ||
|
|
||
| currentState.lastActivity = log.timestamp; | ||
| currentState.totalCalls++; | ||
| currentState.totalDuration += log.duration; | ||
|
|
||
| if (log.error) { | ||
| currentState.totalErrors++; | ||
| } | ||
|
|
||
| sessionStats.set(sessionId, currentState); | ||
| } | ||
|
|
||
| getState(sessionId: string): LoggingState { | ||
| let state = sessionStats.get(sessionId); | ||
| if (!state) { | ||
| // Initialize new state | ||
| state = { | ||
| sessionId, | ||
| userId: '', | ||
| startedAt: Date.now(), | ||
| lastActivity: Date.now(), | ||
| totalCalls: 0, | ||
| totalErrors: 0, | ||
| totalDuration: 0, | ||
| }; | ||
| sessionStats.set(sessionId, state); | ||
| } | ||
| return state; | ||
| } | ||
|
|
||
| initializeSession(sessionId: string, userId: string): void { | ||
| const state = this.getState(sessionId); | ||
| state.userId = userId; | ||
| state.sessionId = sessionId; | ||
| state.startedAt = Date.now(); | ||
| state.lastActivity = Date.now(); | ||
| sessionStats.set(sessionId, state); | ||
| } | ||
|
|
||
| getSessionStats(sessionId: string): SessionStats { | ||
| const state = this.getState(sessionId); | ||
| const sessionDuration = Date.now() - state.startedAt; | ||
|
|
||
| return { | ||
| totalCalls: state.totalCalls, | ||
| totalErrors: state.totalErrors, | ||
| totalDuration: state.totalDuration, | ||
| averageDuration: state.totalCalls > 0 ? state.totalDuration / state.totalCalls : 0, | ||
| errorRate: state.totalCalls > 0 ? (state.totalErrors / state.totalCalls) * 100 : 0, | ||
| sessionDuration, | ||
| }; | ||
| } | ||
|
|
||
| clearSession(sessionId: string): void { | ||
| const newState: LoggingState = { | ||
| sessionId, | ||
| userId: '', | ||
| startedAt: Date.now(), | ||
| lastActivity: Date.now(), | ||
| totalCalls: 0, | ||
| totalErrors: 0, | ||
| totalDuration: 0, | ||
| }; | ||
| sessionStats.set(sessionId, newState); | ||
| } | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.