Skip to content

Commit 03cde7e

Browse files
authored
feat(otel): added otel, persist settings to db, present user with the telemetry preferences & add privacy tab to settings (#318)
* feat(otel): added otel, persist settings to db, present user with the telemetry preferences & add privacy tab to settings * updated telemetry endpoint * add protected subdomains for chat deploy * removed unused dependencies * add execution telemetry logs for workflow-level and block-level logs, acknowledged PR comments
1 parent 209f9ad commit 03cde7e

26 files changed

Lines changed: 5684 additions & 388 deletions

File tree

.gitignore

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,4 +65,12 @@ docs/.contentlayer
6565
docs/.content-collections
6666

6767
# database instantiation
68-
**/postgres_data/
68+
**/postgres_data/
69+
70+
# file uploads
71+
uploads/
72+
73+
# collector configuration
74+
collector-config.yaml
75+
docker-compose.collector.yml
76+
start-collector.sh

sim/app/api/chat/subdomains/validate/route.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,29 @@ describe('Subdomain Validation API Route', () => {
162162
subdomain: 'available-subdomain',
163163
})
164164
})
165+
166+
it('should return available=false when subdomain is reserved', async () => {
167+
vi.doMock('@/lib/auth', () => ({
168+
getSession: vi.fn().mockResolvedValue({
169+
user: { id: 'user-id' },
170+
}),
171+
}))
172+
173+
const req = new NextRequest('http://localhost:3000/api/chat/subdomains/validate?subdomain=telemetry')
174+
175+
const { GET } = await import('./route')
176+
177+
const response = await GET(req)
178+
const data = await response.json()
179+
180+
expect(response.status).toBe(400)
181+
expect(data).toHaveProperty('available', false)
182+
expect(data).toHaveProperty('error', 'This subdomain is reserved')
183+
expect(mockNextResponseJson).toHaveBeenCalledWith(
184+
{ available: false, error: 'This subdomain is reserved' },
185+
{ status: 400 }
186+
)
187+
})
165188

166189
it('should return available=false when subdomain is already in use', async () => {
167190
vi.doMock('@/lib/auth', () => ({

sim/app/api/chat/subdomains/validate/route.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,18 @@ export async function GET(request: Request) {
3434
{ status: 400 }
3535
)
3636
}
37+
38+
// Protect reserved subdomains
39+
const reservedSubdomains = ['telemetry', 'docs', 'api', 'admin', 'www', 'app', 'auth', 'blog', 'help', 'support'];
40+
if (reservedSubdomains.includes(subdomain)) {
41+
return NextResponse.json(
42+
{
43+
available: false,
44+
error: 'This subdomain is reserved'
45+
},
46+
{ status: 400 }
47+
)
48+
}
3749

3850
// Query database to see if subdomain already exists
3951
const existingDeployment = await db

sim/app/api/telemetry/route.ts

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
import { NextRequest, NextResponse } from 'next/server'
2+
import { createLogger } from '@/lib/logs/console-logger'
3+
4+
const logger = createLogger('TelemetryAPI')
5+
6+
const ALLOWED_CATEGORIES = [
7+
'page_view',
8+
'feature_usage',
9+
'performance',
10+
'error',
11+
'workflow',
12+
'consent',
13+
]
14+
15+
const DEFAULT_TIMEOUT = 5000 // 5 seconds timeout
16+
17+
/**
18+
* Validates telemetry data to ensure it doesn't contain sensitive information
19+
*/
20+
function validateTelemetryData(data: any): boolean {
21+
if (!data || typeof data !== 'object') {
22+
return false
23+
}
24+
25+
if (!data.category || !data.action) {
26+
return false
27+
}
28+
29+
if (!ALLOWED_CATEGORIES.includes(data.category)) {
30+
return false
31+
}
32+
33+
const jsonStr = JSON.stringify(data).toLowerCase()
34+
const sensitivePatterns = [
35+
/password/,
36+
/token/,
37+
/secret/,
38+
/key/,
39+
/auth/,
40+
/credential/,
41+
/private/,
42+
]
43+
44+
return !sensitivePatterns.some(pattern => pattern.test(jsonStr))
45+
}
46+
47+
/**
48+
* Safely converts a value to string, handling undefined and null values
49+
*/
50+
function safeStringValue(value: any): string {
51+
if (value === undefined || value === null) {
52+
return ''
53+
}
54+
55+
try {
56+
return String(value)
57+
} catch (e) {
58+
return ''
59+
}
60+
}
61+
62+
/**
63+
* Creates a safe attribute object for OpenTelemetry
64+
*/
65+
function createSafeAttributes(data: Record<string, any>): Array<{key: string, value: {stringValue: string}}> {
66+
if (!data || typeof data !== 'object') {
67+
return []
68+
}
69+
70+
const attributes: Array<{key: string, value: {stringValue: string}}> = []
71+
72+
Object.entries(data).forEach(([key, value]) => {
73+
if (value !== undefined && value !== null && key) {
74+
attributes.push({
75+
key,
76+
value: { stringValue: safeStringValue(value) }
77+
})
78+
}
79+
})
80+
81+
return attributes
82+
}
83+
84+
/**
85+
* Forwards telemetry data to OpenTelemetry collector
86+
*/
87+
async function forwardToCollector(data: any): Promise<boolean> {
88+
if (!data || typeof data !== 'object') {
89+
logger.error('Invalid telemetry data format')
90+
return false
91+
}
92+
93+
const endpoint = process.env.TELEMETRY_ENDPOINT || 'https://telemetry.simstudio.ai/v1/traces'
94+
const timeout = parseInt(process.env.TELEMETRY_TIMEOUT || '') || DEFAULT_TIMEOUT
95+
96+
try {
97+
const timestamp = Date.now() * 1000000
98+
99+
const safeAttrs = createSafeAttributes(data)
100+
101+
const serviceAttrs = [
102+
{ key: 'service.name', value: { stringValue: 'sim-studio' } },
103+
{ key: 'service.version', value: { stringValue: process.env.NEXT_PUBLIC_APP_VERSION || '0.1.0' } },
104+
{ key: 'deployment.environment', value: { stringValue: process.env.NODE_ENV || 'production' } }
105+
]
106+
107+
const spanName = data.category && data.action ? `${data.category}.${data.action}` : 'telemetry.event'
108+
109+
const payload = {
110+
resourceSpans: [{
111+
resource: {
112+
attributes: serviceAttrs
113+
},
114+
instrumentationLibrarySpans: [{
115+
spans: [{
116+
name: spanName,
117+
kind: 1,
118+
startTimeUnixNano: timestamp,
119+
endTimeUnixNano: timestamp + 1000000,
120+
attributes: safeAttrs
121+
}]
122+
}]
123+
}]
124+
}
125+
126+
// Safe debug log of the payload structure without sensitive data
127+
logger.debug('Preparing to send telemetry payload', {
128+
endpoint,
129+
hasAttributes: safeAttrs.length > 0,
130+
attributeCount: safeAttrs.length
131+
})
132+
133+
// Create explicit AbortController for timeout
134+
const controller = new AbortController()
135+
const timeoutId = setTimeout(() => controller.abort(), timeout)
136+
137+
try {
138+
const options = {
139+
method: 'POST',
140+
headers: {
141+
'Content-Type': 'application/json'
142+
},
143+
body: JSON.stringify(payload),
144+
signal: controller.signal
145+
}
146+
147+
const response = await fetch(endpoint, options)
148+
clearTimeout(timeoutId)
149+
150+
if (!response.ok) {
151+
logger.error('Telemetry collector returned error', {
152+
status: response.status,
153+
statusText: response.statusText
154+
})
155+
return false
156+
}
157+
158+
return true
159+
} catch (fetchError) {
160+
clearTimeout(timeoutId)
161+
if (fetchError instanceof Error && fetchError.name === 'AbortError') {
162+
logger.error('Telemetry request timed out', { endpoint })
163+
} else {
164+
logger.error('Failed to send telemetry to collector', fetchError)
165+
}
166+
return false
167+
}
168+
} catch (error) {
169+
logger.error('Error preparing telemetry payload', error)
170+
return false
171+
}
172+
}
173+
174+
/**
175+
* Endpoint that receives telemetry events and forwards them to OpenTelemetry collector
176+
*/
177+
export async function POST(req: NextRequest) {
178+
try {
179+
let eventData
180+
try {
181+
eventData = await req.json()
182+
} catch (parseError) {
183+
return NextResponse.json(
184+
{ error: 'Invalid JSON in request body' },
185+
{ status: 400 }
186+
)
187+
}
188+
189+
if (!validateTelemetryData(eventData)) {
190+
return NextResponse.json(
191+
{ error: 'Invalid telemetry data format or contains sensitive information' },
192+
{ status: 400 }
193+
)
194+
}
195+
196+
const forwarded = await forwardToCollector(eventData)
197+
198+
return NextResponse.json({
199+
success: true,
200+
forwarded
201+
})
202+
} catch (error) {
203+
logger.error('Error processing telemetry event', error)
204+
return NextResponse.json(
205+
{ error: 'Failed to process telemetry event' },
206+
{ status: 500 }
207+
)
208+
}
209+
}

0 commit comments

Comments
 (0)