-
Notifications
You must be signed in to change notification settings - Fork 60
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Implements the first version of telemetry. - Created telemetry js package and added it to latitude's TS SDK. - Telemetry supports automatic telemetry data collection for the following providers: - OpenAI - Anthropic - Azure AI - Cohere - Google Vertex AI - Google AI Platform - AWS Bedrock - Vercel SDK (and all its providers) - Added backend to ingest traces and spans - Added frontend to visualize traces and spans - Added actions to automatically create prompts from spans - Added manual tracing with SDK's telemetry.span() method - Added automatic prompt log creation when a span is associated with a prompt
- Loading branch information
Showing
265 changed files
with
14,464 additions
and
607 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,6 +1,7 @@ | ||
name: latitude-llm | ||
windows: | ||
- web: cd apps/web | ||
- apps: pnpm dev --filter='./apps/*' --filter='./packages/sdks/typescript' --filter='./packages/compiler' | ||
- web: cd . | ||
- apps: pnpm dev --filter='./apps/*' | ||
- packages: pnpm dev --filter='./packages/*' | ||
- docker: docker compose up --menu=false | ||
- studio: cd packages/core && pnpm db:studio |
300 changes: 300 additions & 0 deletions
300
apps/gateway/src/routes/api/v2/otlp/traces/handlers/otlp.test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,300 @@ | ||
import { Project, Workspace } from '@latitude-data/core/browser' | ||
import { unsafelyGetFirstApiKeyByWorkspaceId } from '@latitude-data/core/data-access' | ||
import { createProject } from '@latitude-data/core/factories' | ||
import app from '$/routes/app' | ||
import { beforeEach, describe, expect, it, vi } from 'vitest' | ||
import { otlpTraceHandler } from './otlp' | ||
import { setupJobs } from '@latitude-data/core/jobs' | ||
|
||
// Mock the jobs setup | ||
vi.mock('@latitude-data/core/jobs', () => ({ | ||
setupJobs: vi.fn(), | ||
})) | ||
|
||
describe('POST /api/v2/otlp/v1/traces', () => { | ||
let workspace: Workspace | ||
let project: Project | ||
let route: string | ||
let headers: Record<string, string> | ||
|
||
beforeEach(async () => { | ||
const setup = await createProject() | ||
workspace = setup.workspace | ||
project = setup.project | ||
|
||
const apikey = await unsafelyGetFirstApiKeyByWorkspaceId({ | ||
workspaceId: workspace.id, | ||
}).then((r) => r.unwrap()) | ||
|
||
route = '/api/v2/otlp/v1/traces' | ||
headers = { | ||
Authorization: `Bearer ${apikey.token}`, | ||
'Content-Type': 'application/json', | ||
} | ||
}) | ||
|
||
const createBasicSpan = (traceId: string, spanId: string) => ({ | ||
traceId, | ||
spanId, | ||
name: 'test-span', | ||
kind: 1, | ||
startTimeUnixNano: (Date.now() * 1_000_000).toString(), | ||
endTimeUnixNano: (Date.now() * 1_000_000 + 1_000_000_000).toString(), | ||
attributes: [{ key: 'test.attribute', value: { stringValue: 'test' } }], | ||
status: { code: 1, message: 'Success' }, | ||
}) | ||
|
||
const createOtlpRequest = (spans: any[]) => ({ | ||
projectId: project.id, | ||
resourceSpans: [ | ||
{ | ||
resource: { | ||
attributes: [ | ||
{ | ||
key: 'service.name', | ||
value: { stringValue: 'test-service' }, | ||
}, | ||
], | ||
}, | ||
scopeSpans: [{ spans }], | ||
}, | ||
], | ||
}) | ||
|
||
describe('when authorized', () => { | ||
it('processes single span', async () => { | ||
const span = createBasicSpan( | ||
'12345678901234567890123456789012', | ||
'1234567890123456', | ||
) | ||
|
||
const response = await app.request(route, { | ||
method: 'POST', | ||
headers, | ||
body: JSON.stringify(createOtlpRequest([span])), | ||
}) | ||
|
||
expect(response.status).toBe(200) | ||
expect(await response.json()).toEqual({ status: 'ok' }) | ||
}) | ||
|
||
it('handles batch of spans', async () => { | ||
const spans = Array.from({ length: 10 }, (_, i) => | ||
createBasicSpan( | ||
'12345678901234567890123456789012', | ||
i.toString(16).padStart(16, '0'), | ||
), | ||
) | ||
|
||
const response = await app.request(route, { | ||
method: 'POST', | ||
headers, | ||
body: JSON.stringify(createOtlpRequest(spans)), | ||
}) | ||
|
||
expect(response.status).toBe(200) | ||
expect(await response.json()).toEqual({ status: 'ok' }) | ||
}) | ||
|
||
it('processes multiple traces', async () => { | ||
const spans = Array.from({ length: 3 }, (_, i) => | ||
createBasicSpan( | ||
`${'0'.repeat(29)}${(i + 1).toString().padStart(3, '0')}`, | ||
i.toString(16).padStart(16, '0'), | ||
), | ||
) | ||
|
||
const response = await app.request(route, { | ||
method: 'POST', | ||
headers, | ||
body: JSON.stringify(createOtlpRequest(spans)), | ||
}) | ||
|
||
expect(response.status).toBe(200) | ||
expect(await response.json()).toEqual({ status: 'ok' }) | ||
}) | ||
}) | ||
|
||
describe('when unauthorized', () => { | ||
it('returns 401 without auth token', async () => { | ||
const response = await app.request(route, { | ||
method: 'POST', | ||
headers: { 'Content-Type': 'application/json' }, | ||
body: JSON.stringify({ resourceSpans: [] }), | ||
}) | ||
|
||
expect(response.status).toBe(401) | ||
}) | ||
}) | ||
}) | ||
|
||
describe('otlpTraceHandler', () => { | ||
const mockQueue = { | ||
jobs: { | ||
enqueueProcessOtlpTracesJob: vi.fn(), | ||
}, | ||
} | ||
|
||
beforeEach(() => { | ||
vi.clearAllMocks() | ||
;(setupJobs as any).mockResolvedValue({ | ||
defaultQueue: mockQueue, | ||
}) | ||
}) | ||
|
||
it('should process and enqueue spans in batches', async () => { | ||
// Create test data with multiple spans | ||
const testData = { | ||
resourceSpans: [ | ||
{ | ||
resource: { | ||
attributes: [ | ||
{ | ||
key: 'service.name', | ||
value: { stringValue: 'test-service' }, | ||
}, | ||
], | ||
}, | ||
scopeSpans: [ | ||
{ | ||
spans: Array.from({ length: 75 }, (_, i) => ({ | ||
traceId: `trace-${i}`, | ||
spanId: `span-${i}`, | ||
name: `span-${i}`, | ||
kind: 1, | ||
startTimeUnixNano: '1234567890', | ||
})), | ||
}, | ||
], | ||
}, | ||
], | ||
} | ||
|
||
const mockContext = { | ||
req: { | ||
valid: () => testData, | ||
}, | ||
get: () => ({ id: 'test-workspace' }), | ||
json: vi.fn(), | ||
} | ||
|
||
await otlpTraceHandler(mockContext as any) | ||
|
||
// With BATCH_SIZE = 50, we expect 2 batches (50 + 25 spans) | ||
expect(mockQueue.jobs.enqueueProcessOtlpTracesJob).toHaveBeenCalledTimes(2) | ||
|
||
// Check first batch | ||
expect(mockQueue.jobs.enqueueProcessOtlpTracesJob).toHaveBeenCalledWith({ | ||
spans: expect.arrayContaining([ | ||
expect.objectContaining({ | ||
span: expect.objectContaining({ spanId: 'span-0' }), | ||
resourceAttributes: expect.arrayContaining([ | ||
expect.objectContaining({ | ||
key: 'service.name', | ||
value: { stringValue: 'test-service' }, | ||
}), | ||
]), | ||
}), | ||
]), | ||
workspace: { id: 'test-workspace' }, | ||
}) | ||
|
||
// Verify response | ||
expect(mockContext.json).toHaveBeenCalledWith({ status: 'ok' }) | ||
}) | ||
|
||
it('should handle empty spans array', async () => { | ||
const testData = { | ||
resourceSpans: [ | ||
{ | ||
resource: { | ||
attributes: [], | ||
}, | ||
scopeSpans: [ | ||
{ | ||
spans: [], | ||
}, | ||
], | ||
}, | ||
], | ||
} | ||
|
||
const mockContext = { | ||
req: { | ||
valid: () => testData, | ||
}, | ||
get: () => ({ id: 'test-workspace' }), | ||
json: vi.fn(), | ||
} | ||
|
||
await otlpTraceHandler(mockContext as any) | ||
|
||
expect(mockQueue.jobs.enqueueProcessOtlpTracesJob).not.toHaveBeenCalled() | ||
expect(mockContext.json).toHaveBeenCalledWith({ status: 'ok' }) | ||
}) | ||
|
||
it('should preserve resource attributes for each span', async () => { | ||
const testData = { | ||
resourceSpans: [ | ||
{ | ||
resource: { | ||
attributes: [ | ||
{ | ||
key: 'service.name', | ||
value: { stringValue: 'test-service' }, | ||
}, | ||
{ | ||
key: 'deployment.environment', | ||
value: { stringValue: 'production' }, | ||
}, | ||
], | ||
}, | ||
scopeSpans: [ | ||
{ | ||
spans: [ | ||
{ | ||
traceId: 'trace-1', | ||
spanId: 'span-1', | ||
name: 'test-span', | ||
kind: 1, | ||
startTimeUnixNano: '1234567890', | ||
}, | ||
], | ||
}, | ||
], | ||
}, | ||
], | ||
} | ||
|
||
const mockContext = { | ||
req: { | ||
valid: () => testData, | ||
}, | ||
get: () => ({ id: 'test-workspace' }), | ||
json: vi.fn(), | ||
} | ||
|
||
await otlpTraceHandler(mockContext as any) | ||
|
||
expect(mockQueue.jobs.enqueueProcessOtlpTracesJob).toHaveBeenCalledWith({ | ||
spans: [ | ||
{ | ||
span: expect.objectContaining({ | ||
spanId: 'span-1', | ||
}), | ||
resourceAttributes: expect.arrayContaining([ | ||
expect.objectContaining({ | ||
key: 'service.name', | ||
value: { stringValue: 'test-service' }, | ||
}), | ||
expect.objectContaining({ | ||
key: 'deployment.environment', | ||
value: { stringValue: 'production' }, | ||
}), | ||
]), | ||
}, | ||
], | ||
workspace: { id: 'test-workspace' }, | ||
}) | ||
}) | ||
}) |
Oops, something went wrong.