Skip to content
Merged
27 changes: 27 additions & 0 deletions apps/mail/lib/trpc.ts
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,
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');
}
}
return res;
}),
}),
],
});
1 change: 1 addition & 0 deletions apps/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
"@arcadeai/arcadejs": "1.8.1",
"@barkleapp/css-sanitizer": "1.0.0",
"@coinbase/cookie-manager": "1.1.8",
"@datadog/datadog-api-client": "1.40.0",
"@dub/better-auth": "0.0.3",
"@googleapis/gmail": "12.0.0",
"@googleapis/people": "3.0.9",
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/ctx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ export type HonoVariables = {
auth: Auth;
sessionUser?: SessionUser;
autumn?: Autumn;
traceId?: string;
requestId?: string;
};

export type HonoContext = { Variables: HonoVariables; Bindings: ZeroEnv };
5 changes: 5 additions & 0 deletions apps/server/src/env.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { ThinkingMCP, ThreadSyncWorker, WorkflowRunner, ZeroDB, ZeroMCP } from './main';
import type { ShardRegistry, ZeroAgent, ZeroDriver } from './routes/agent';

import { env as _env } from 'cloudflare:workers';
import type { QueryableHandler } from 'dormroom';

Expand All @@ -11,6 +12,7 @@ export type ZeroEnv = {
ZERO_MCP: DurableObjectNamespace<ZeroMCP & QueryableHandler>;
THINKING_MCP: DurableObjectNamespace<ThinkingMCP & QueryableHandler>;
WORKFLOW_RUNNER: DurableObjectNamespace<WorkflowRunner & QueryableHandler>;

THREAD_SYNC_WORKER: DurableObjectNamespace<ThreadSyncWorker>;
SYNC_THREADS_WORKFLOW: Workflow;
SYNC_THREADS_COORDINATOR_WORKFLOW: Workflow;
Expand Down Expand Up @@ -97,6 +99,9 @@ export type ZeroEnv = {
OTEL_EXPORTER_OTLP_ENDPOINT?: string;
OTEL_EXPORTER_OTLP_HEADERS?: string;
OTEL_SERVICE_NAME?: string;
DD_API_KEY: string;
DD_APP_KEY: string;
DD_SITE: string;
};

const env = _env as ZeroEnv;
Expand Down
206 changes: 206 additions & 0 deletions apps/server/src/lib/datadog-service.ts
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,
};

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;
}
}

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: {
// 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,
Copy link
Contributor

Choose a reason for hiding this comment

The 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
Address the following comment on apps/server/src/lib/datadog-service.ts at line 188:

<comment>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.</comment>

<file context>
@@ -0,0 +1,212 @@
+import { client, v2 } from &#39;@datadog/datadog-api-client&#39;;
+import type { TRPCCallLog } from &#39;./logging-durable-object&#39;;
+import type { ZeroEnv } from &#39;../env&#39;;
+
+export class DatadogService {
+    private apiInstance: v2.LogsApi;
+    private apiKey: string;
+    private appKey: string;
+    private site: string;
</file context>

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

worth review

...(log.output && {
response_payload: log.output,
Copy link
Contributor

Choose a reason for hiding this comment

The 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
Address the following comment on apps/server/src/lib/datadog-service.ts at line 190:

<comment>Logging the full response payload may leak sensitive data (PII or secrets) and inflate log size. Prefer redacting or only including minimal metadata/errors.</comment>

<file context>
@@ -0,0 +1,212 @@
+import { client, v2 } from &#39;@datadog/datadog-api-client&#39;;
+import type { TRPCCallLog } from &#39;./logging-durable-object&#39;;
+import type { ZeroEnv } from &#39;../env&#39;;
+
+export class DatadogService {
+    private apiInstance: v2.LogsApi;
+    private apiKey: string;
+    private appKey: string;
+    private site: string;
</file context>

}),

// 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);
}
}
}
117 changes: 117 additions & 0 deletions apps/server/src/lib/logging-service.ts
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);
}
}
Loading