Skip to content

Commit

Permalink
feat(telemetry): Telemetry MVP
Browse files Browse the repository at this point in the history
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
geclos committed Dec 12, 2024
1 parent 33c8148 commit 89e93d7
Show file tree
Hide file tree
Showing 265 changed files with 14,424 additions and 607 deletions.
5 changes: 3 additions & 2 deletions .tmuxinator.yml
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 apps/gateway/src/routes/api/v2/otlp/traces/handlers/otlp.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,300 @@
import { 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 route: string
let headers: Record<string, string>

beforeEach(async () => {
const setup = await createProject()
workspace = setup.workspace

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[]) => ({
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(),
}

// @ts-ignore
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(),
}

// @ts-ignore
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(),
}

// @ts-ignore
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' },
})
})
})
Loading

0 comments on commit 89e93d7

Please sign in to comment.