lumina-sdk
v2.0.9
Published
Grounded Intelligence SDK: Connect your AI workflows to every surrounding data stream — session replays, user feedback, observability traces, and more.
Readme
Grounded Intelligence SDK
Connect your AI workflows to every surrounding data stream — session replays, user feedback, observability traces, and more.
📋 Table of Contents
- The Problem
- The Solution
- Installation
- Quick Start
- Core Concepts
- API Reference
- User Identity Management
- Provider Integration
- Advanced Features
- TypeScript Support
- Best Practices
The Problem
Building is no longer the bottleneck — understanding what to build is.
Product teams are building blindly, asking critical questions that traditional analytics can't answer:
- What actually moves the needle on user retention?
- What turns first-time users into power users?
- Where does my AI product produce the "AHA" moment, and when does it create friction?
These answers live in every user interaction pattern, but extracting value is fractured and manual for two key reasons:
1. User data lives in silos across your entire stack
- Explicit feedback (NPS surveys, thumbs up/down, customer support) captures a small fraction of users who voluntarily articulate what frustrated them
- Implicit behavior (session replays, click patterns) shows what led users to abandon
- System traces show when services failed during their session
Each data stream lives in disconnected tools — the complete user story emerges only when you integrate and analyze them together.
2. AI is the central workflow but analytics hasn't adapted
Users now increasingly interact with products through AI assistants and agents — taking actions, completing tasks, and answering questions through natural language. Current analytics tooling is built for traditional click-based workflows, not AI-centric ones.
Evaluating the success and relevance of AI systems requires connecting them to surrounding context: what the user did before the conversation, what happened during generation, and what happened after.
The Solution
Grounded Intelligence connects your AI workflows to every surrounding data stream and actively watches them — session replays (available now), customer support conversations (coming soon), observability traces (coming soon), payment events (coming soon), and user feedback (coming soon).
The SDK puts AI at the center while seamlessly integrating with your existing analytics providers. Track every LLM conversation alongside the complete user journey.
What This SDK Enables
- Capture AI Conversations: Track every user ↔ assistant interaction with automatic latency, error capture, and context
- Connect to Session Replays: Correlate AI conversations with session replays (PostHog integration available now, more providers coming)
- Track Tool & API Calls: Monitor external API calls, database queries, and function executions made by your AI
- Unified User Identity: Seamlessly transition from anonymous to identified users across all data streams
- Privacy-First: Built-in transcript masking and PII filtering
Key Features
- Minimal Overhead: Buffered NDJSON transport with automatic batching
- Session Management: W3C Trace Context standard for distributed tracing
- Persistent Identity: Cookie + localStorage for identity across sessions and tabs
- Provider Agnostic: Pluggable integration with UI analytics (PostHog now, more coming) and system observability
- Type Safe: Full TypeScript support with exported types
What's Coming
Grounded Intelligence is actively building integrations for:
- Customer support platforms (Intercom, Zendesk)
- Observability providers (DataDog, Sentry, OpenTelemetry)
- Payment & business events (Stripe, custom webhooks)
- User feedback systems (in-app surveys, feature requests)
Installation
npm install luminasdkNote: The package is installed as
luminasdk, but throughout the codebase and documentation, we refer to it as the Grounded Intelligence SDK.
Optional Peer Dependencies
For UI provider integration:
npm install posthog-js # For PostHog integrationQuick Start
1. Initialize the SDK
import { Lumina, CaptureTranscript } from 'luminasdk'
Lumina.init({
endpoint: 'https://your-ingest-server.com',
writeKey: 'gi_xxxxx', // API key from your Grounded Intelligence dashboard
captureTranscript: CaptureTranscript.Full,
maskFn: (text) => text,
enableToolWrapping: true,
flushIntervalMs: 5000,
maxBatchBytes: 100_000,
uiAnalytics: createDummyProvider(), // See Provider Integration below
})2. Start a Session and Capture a Turn
// Start a session (once per conversation)
const session = await Lumina.session.start()
// Create a turn (once per user message → assistant response)
const turn = session.turn()
// Wrap your LLM call
const response = await turn.wrapLLM(
async () => {
// Your OpenAI/Anthropic/etc call here
return await openai.chat.completions.create({
model: 'gpt-4o',
messages: [{ role: 'user', content: 'Hello!' }],
})
},
{ model: 'gpt-4o', prompt_id: 'greeting_v1' }
)
// Provide transcript (respects captureTranscript setting)
turn.setMessages([
{ role: 'user', content: 'Hello!' },
{ role: 'assistant', content: response.choices[0].message.content || '' },
])
// Finish turn (sends event to ingest)
await turn.finish()Core Concepts
Turn Event
A turn represents a single user ↔ assistant interaction. Each turn captures:
- Latency: Time from LLM call start to completion
- Model & Prompt: Which model and prompt version was used
- Transcript: User and assistant messages (with privacy controls)
- Tool Calls: External API/database calls made during the turn
- Retrieval: Context retrieved for RAG applications
- Annotations: Custom metadata (UI session IDs, replay URLs, etc.)
Session
A session is a sequence of turns under a single trace ID. Sessions:
- Use W3C Trace Context standard (
traceparentheader format) - Persist across page reloads via
sessionStorage - Reset on user identity changes (optional)
Identity
User identity can be:
- Anonymous: Auto-generated
anon_<uuid>on first use - Identified: Explicitly set via
Lumina.identify(userId, properties)
Identity is persisted in:
- Cookies: Primary storage (
lumina_id, 1-year expiry) - localStorage: Backup storage
API Reference
Initialization
Lumina.init(config: LuminaConfig)
Initialize the SDK once at app startup.
Config:
{
writeKey: string // API key from dashboard (gi_...)
endpoint: string // Ingest server URL
captureTranscript: CaptureTranscript // Full | Masked | None
maskFn: (text: string) => string // PII masking function
enableToolWrapping: boolean // Enable automatic tool call tracking
flushIntervalMs: number // Buffer flush interval (default: 5000ms)
maxBatchBytes: number // Max batch size before auto-flush (default: 100KB)
uiAnalytics: UIAnalyticsProvider // UI provider (PostHog, etc.)
systemAnalytics?: SystemAnalyticsProvider // Optional: system tracing provider
}CaptureTranscript Options:
CaptureTranscript.Full: Capture all messages verbatimCaptureTranscript.Masked: ApplymaskFnbefore sendingCaptureTranscript.None: Don't capture transcript content
Session Management
await Lumina.session.start() → SessionCtx
Start a new session. Returns a session context with a turn() method.
Returns:
{
turn: () => TurnCtx
}Note: Multiple session.start() calls will reuse the same trace ID unless the session is explicitly cleared.
Turn Management
session.turn() → TurnCtx
Create a new turn context. Each turn gets a unique span ID and incremented sequence number.
Turn Context Methods:
await turn.wrapLLM<T>(fn: () => Promise<T>, meta: { model: string; prompt_id: string }) → Promise<T>
Wrap an LLM call to capture latency, model, and errors.
Example:
const response = await turn.wrapLLM(
async () => await openai.chat.completions.create({ /* ... */ }),
{ model: 'gpt-4o', prompt_id: 'chat_v1' }
)Captures:
- Latency (milliseconds)
- Model name
- Prompt ID (for versioning)
- Error state (if thrown)
Does NOT capture:
- Token usage (must be added via
annotate()) - Streaming progress
await turn.recordTool<T>(name: string, fn: () => Promise<T>, opts: { type: string; target: string; version: string }) → Promise<T>
Wrap a tool call (API, database, function) to capture execution metrics.
Example:
const results = await turn.recordTool(
'search_docs',
async () => await vectorDB.search(query),
{ type: 'vector_search', target: 'pinecone', version: 'v1' }
)Captures:
- Success/failure
- Latency
- Error message (if thrown)
Note: Only active if enableToolWrapping: true in config.
turn.addRetrieval(info: RetrievalInfo)
Attach retrieval metadata for RAG applications.
Example:
turn.addRetrieval({
source: 'docs',
query: 'How do I reset my password?',
results: [
{ id: 'doc_123', score: 0.95, content: '...' },
{ id: 'doc_456', score: 0.87 },
],
})turn.annotate(obj: Record<string, any>)
Attach custom metadata to the turn.
Example:
turn.annotate({
ui_session_id: posthog.get_session_id(),
ui_replay_url: 'https://app.posthog.com/...',
custom_tag: 'high_priority',
})Common Use Cases:
- UI session IDs and replay URLs
- Feature flags
- A/B test variants
- Custom business logic metadata
turn.setMessages(msgs: Array<{ role: string; content: string }>)
Set the conversation transcript for this turn.
Example:
turn.setMessages([
{ role: 'user', content: 'What is the weather?' },
{ role: 'assistant', content: 'The weather is sunny.' },
])Privacy:
CaptureTranscript.Full: Sent as-isCaptureTranscript.Masked: Content passed throughmaskFnbefore sendingCaptureTranscript.None: Content not sent (only metadata)
await turn.finish()
Finalize the turn and flush to ingest server.
Important:
- Always call
finish()to ensure events are sent - Use
try/finallyblocks to guarantee execution - Calling
finish()multiple times is safe (idempotent after first call)
Example:
const turn = session.turn()
try {
// ... LLM call, tool calls, etc.
await turn.finish()
} catch (error) {
// Error handling
}User Identity
await Lumina.identify(userId: string, properties?: Record<string, any>, opts?: { newSession?: boolean })
Identify the current user. Switches from anonymous to known identity.
Parameters:
userId: Unique user identifier (e.g., database ID, email)properties: Optional user properties (name, email, etc.)opts.newSession: Iftrue, clears current session and starts fresh
Example:
// After user login
await Lumina.identify('user_123', {
email: '[email protected]',
name: 'John Doe',
})Behavior:
- Persists
userIdto cookie and localStorage - Updates
distinctIdon all subsequent events - Sends identify event to backend for user aliasing
- Syncs identity to UI provider (PostHog, Amplitude, etc.) if supported
- If user changes, clears session by default
- Flushes buffer immediately to ensure alias is created
Lumina.reset()
Reset to anonymous identity. Typically called on logout.
Behavior:
- Generates new
anon_<uuid>ID - Clears session trace ID
- Persists new anonymous ID to cookie and localStorage
Example:
// On user logout
Lumina.reset()Lumina.getDistinctId() → string
Get the current user's distinct ID (anonymous or identified).
Example:
const userId = Lumina.getDistinctId()
console.log(userId) // 'anon_abc123' or 'user_123'Lumina.isAnonymous() → boolean
Check if the current user is anonymous.
Example:
if (Lumina.isAnonymous()) {
console.log('User not logged in')
}User Identity Management
Identity Lifecycle
- First Visit: Auto-generates
anon_<uuid> - Login: Call
identify(userId)to switch to known user - Logout: Call
reset()to return to anonymous - Return Visit: Loads identity from cookie/localStorage
Storage Mechanism
Primary: Cookies
- Key:
lumina_id - Expiry: 1 year
- SameSite: Lax
- Path: /
Backup: localStorage
- Key:
lumina_id - No expiry (unless manually cleared)
Session Continuity
Sessions persist across page reloads via sessionStorage (traceparent key). Sessions are cleared when:
- User identity changes
Lumina.reset()is calledsessionStorageis manually cleared
Provider Integration
PostHog Integration
Connect your AI conversations with session replays to see the complete user journey — what they clicked, where they navigated, and what they did before and after each AI interaction.
Coming Soon: Integrations for Amplitude, FullStory, LogRocket, and custom replay providers.
Setup
import posthog from 'posthog-js'
import { postHogProvider } from 'luminasdk/providers/posthog'
// Initialize PostHog
posthog.init('phc_xxxxx', {
api_host: 'https://us.posthog.com',
session_recording: { recordCrossOriginIframes: true },
})
// Create provider adapter
const uiProvider = postHogProvider({
apiKey: 'phc_xxxxx',
host: 'https://us.posthog.com',
projectId: '12345', // Optional: enables replay URL generation
})
// Initialize SDK with UI provider
Lumina.init({
// ... other config
uiAnalytics: uiProvider,
})Usage
const session = await Lumina.session.start()
const turn = session.turn()
// Attach UI pointers via annotations
turn.annotate({
ui_session_id: uiProvider.getSessionId(),
ui_replay_url: uiProvider.getReplayUrl(),
})
await turn.finish()How It Works
- Super Properties: SDK tags PostHog events with
lumina_session_id,lumina_turn_id,lumina_turn_seq, andlumina_turn_start_ts - Tag/Untag: Properties are registered on
turnstart and unregistered onfinish - Correlation: Enables joining AI conversation events with session replay events in the Grounded Intelligence dashboard
This creates a unified timeline where you can:
- Jump from an AI conversation directly to the session replay
- See what UI actions preceded or followed each AI interaction
- Understand the full context of user behavior patterns
Dummy Provider (No UI Analytics)
If you don't use a UI analytics provider, use a dummy implementation:
function createDummyProvider() {
return {
name: 'none',
init: () => {},
getSessionId: () => '',
getReplayUrl: () => '',
tagTurn: () => {},
untagTurn: () => {},
startReplay: () => {},
stopReplay: () => {},
captureEvent: () => {},
shutdown: () => {},
}
}
Lumina.init({
// ... other config
uiAnalytics: createDummyProvider(),
})Advanced Features
Chat Input Tracking
Track implicit user behavior signals like typing duration, hesitation, and abandonment using ChatInputTracker. These signals reveal user friction points that explicit feedback misses.
Setup:
import { ChatInputTracker } from 'luminasdk'
const session = await Lumina.session.start()
const tracker = new ChatInputTracker(uiProvider, sessionId)Usage:
// When user focuses on input
inputElement.addEventListener('focus', () => {
tracker.onInputStart()
})
// When user submits message
form.addEventListener('submit', () => {
tracker.onMessageSubmit(messageId, inputValue)
})
// When user abandons input (blurs with content)
inputElement.addEventListener('blur', () => {
if (inputElement.value.trim()) {
tracker.onInputAbandoned(inputElement.value)
}
})Captured Events:
chat_input_started: When user begins typingchat_message_submitted: When message is sent (includes typing duration)chat_input_abandoned: When user types but doesn't send (partial content is hashed to avoid PII)
Key Metrics:
typing_duration_ms: Time spent composing the messagecharacter_count: Length of inputpartial_content_hash: Hash of abandoned content (no PII stored)
PII Masking
Mask sensitive data before sending to ingest server.
Lumina.init({
captureTranscript: CaptureTranscript.Masked,
maskFn: (text) => {
// Mask emails
text = text.replace(/\b[\w.-]+@[\w.-]+\.\w{2,}\b/g, '[EMAIL]')
// Mask phone numbers
text = text.replace(/\b\d{3}[-.]?\d{3}[-.]?\d{4}\b/g, '[PHONE]')
// Mask credit cards
text = text.replace(/\b\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{4}\b/g, '[CARD]')
return text
},
})Buffered Transport
Events are buffered and sent in batches to minimize network overhead.
Auto-Flush Triggers:
- Timer: Every
flushIntervalMs(default: 5000ms) - Batch Size: When
maxBatchBytesis exceeded (default: 100KB) - Visibility Change: On tab hide or page unload
Retry Logic:
- 3 retry attempts with linear backoff (300ms, 600ms, 900ms)
- Failed batches are re-queued
Custom System Analytics Provider
Integrate with distributed tracing systems (OpenTelemetry, Datadog, etc.).
const customSystemProvider: SystemAnalyticsProvider = {
name: 'opentelemetry',
startTurn: (ctx) => {
const span = tracer.startSpan('turn', { attributes: ctx })
return { span_id: span.spanContext().spanId }
},
endTurn: (spanId, status, meta) => {
const span = getSpan(spanId)
span.setStatus({ code: status === 'ok' ? SpanStatusCode.OK : SpanStatusCode.ERROR })
span.end()
},
startSpan: (ctx) => { /* ... */ },
endSpan: (spanId, status, meta) => { /* ... */ },
shutdown: () => tracer.shutdown(),
}
Lumina.init({
// ... other config
systemAnalytics: customSystemProvider,
})TypeScript Support
All public types and classes are exported:
import type {
LuminaConfig,
SessionCtx,
TurnCtx,
ToolCall,
RetrievalInfo,
UIAnalyticsProvider,
SystemAnalyticsProvider,
CaptureTranscript,
} from 'luminasdk'
// Classes
import { ChatInputTracker } from 'luminasdk'Type Definitions
LuminaConfig: SDK initialization config
SessionCtx: Session context returned by session.start()
TurnCtx: Turn context returned by session.turn()
ToolCall: Tool call metadata
RetrievalInfo: RAG retrieval metadata
UIAnalyticsProvider: UI provider adapter interface
SystemAnalyticsProvider: System tracing provider interface
CaptureTranscript: Transcript capture mode enum
ChatInputTracker: Class for tracking chat input behavior and abandonment
Best Practices
1. Initialize Once
// ✅ Good: Initialize at app startup
Lumina.init({ /* ... */ })
// ❌ Bad: Initialize on every request
function handleRequest() {
Lumina.init({ /* ... */ }) // Don't do this
}2. Use One Session Per Conversation
// ✅ Good: One session per chat thread
const session = await Lumina.session.start()
// Multiple turns in the same session
for (const userMessage of messages) {
const turn = session.turn()
// ... handle turn
await turn.finish()
}3. Always Call finish()
// ✅ Good: Use try/finally to guarantee finish
const turn = session.turn()
try {
await turn.wrapLLM(/* ... */)
turn.setMessages(/* ... */)
} finally {
await turn.finish()
}
// ❌ Bad: finish() might not be called on error
const turn = session.turn()
await turn.wrapLLM(/* ... */)
await turn.finish() // Skipped if wrapLLM throws4. Identify Users Early
// ✅ Good: Identify immediately after login
async function login(email, password) {
const user = await authenticate(email, password)
await Lumina.identify(user.id, { email: user.email, name: user.name })
}5. Mask PII
// ✅ Good: Use Masked mode with custom maskFn
Lumina.init({
captureTranscript: CaptureTranscript.Masked,
maskFn: (text) => {
// Custom PII masking logic
return text.replace(/\b[\w.-]+@[\w.-]+\.\w{2,}\b/g, '[EMAIL]')
},
})Advanced Use Cases
Streaming LLM Responses
Tracking streaming completions:
import { OpenAI } from 'openai'
const turn = session.turn()
const startTime = Date.now()
try {
const stream = await turn.wrapLLM(
async () => {
return await openai.chat.completions.create({
model: 'gpt-4o',
messages: [{ role: 'user', content: userMessage }],
stream: true,
})
},
{ model: 'gpt-4o', prompt_id: 'chat_v1' }
)
let fullResponse = ''
for await (const chunk of stream) {
const content = chunk.choices[0]?.delta?.content || ''
fullResponse += content
// Display to user in real-time
displayChunk(content)
}
// Record complete transcript
turn.setMessages([
{ role: 'user', content: userMessage },
{ role: 'assistant', content: fullResponse }
])
// Add streaming metadata
turn.annotate({
streaming: true,
totalLatencyMs: Date.now() - startTime,
firstTokenLatencyMs: /* track separately */
})
} finally {
await turn.finish()
}Error Handling & Retries
Robust error handling pattern:
async function robustLLMCall(userMessage: string, maxRetries = 3) {
const session = await Lumina.session.start()
const turn = session.turn()
for (let attempt = 0; attempt < maxRetries; attempt++) {
try {
const response = await turn.wrapLLM(
async () => {
return await openai.chat.completions.create({
model: 'gpt-4o',
messages: [{ role: 'user', content: userMessage }],
})
},
{ model: 'gpt-4o', prompt_id: 'chat_v1' }
)
turn.setMessages([
{ role: 'user', content: userMessage },
{ role: 'assistant', content: response.choices[0].message.content || '' }
])
// Annotate success
turn.annotate({
success: true,
attemptNumber: attempt + 1
})
await turn.finish()
return response
} catch (error) {
// Log error in turn
turn.annotate({
error: {
message: error.message,
type: error.constructor.name,
attempt: attempt + 1
}
})
// Last attempt - give up
if (attempt === maxRetries - 1) {
await turn.finish()
throw error
}
// Exponential backoff
await new Promise(resolve => setTimeout(resolve, 2 ** attempt * 1000))
}
}
}Multi-Model Workflows
Tracking complex AI workflows:
const session = await Lumina.session.start()
// Step 1: Classification with fast model
const classificationTurn = session.turn()
try {
const category = await classificationTurn.wrapLLM(
async () => {
return await openai.chat.completions.create({
model: 'gpt-4o-mini',
messages: [{ role: 'user', content: `Classify: ${userMessage}` }],
})
},
{ model: 'gpt-4o-mini', prompt_id: 'classify_v1' }
)
classificationTurn.annotate({ step: 'classification' })
await classificationTurn.finish()
// Step 2: Generation with powerful model
const generationTurn = session.turn()
const response = await generationTurn.wrapLLM(
async () => {
return await openai.chat.completions.create({
model: 'gpt-4o',
messages: [
{ role: 'system', content: `Category: ${category}` },
{ role: 'user', content: userMessage }
],
})
},
{ model: 'gpt-4o', prompt_id: 'generate_v1' }
)
generationTurn.annotate({
step: 'generation',
category
})
await generationTurn.finish()
} catch (error) {
// Errors tracked per turn
}RAG (Retrieval-Augmented Generation)
Tracking retrieval and context:
const turn = session.turn()
try {
// Retrieve context
const retrievalResults = await turn.recordTool(
'vector_search',
async () => {
return await vectorDB.search(userMessage, { limit: 5 })
},
{ type: 'vector_search', target: 'pinecone', version: 'v1' }
)
// Add retrieval metadata
turn.addRetrieval({
source: 'documentation',
query: userMessage,
results: retrievalResults.map(r => ({
id: r.id,
score: r.score,
content: r.text.substring(0, 200) // Truncate for storage
}))
})
// Generate with context
const context = retrievalResults.map(r => r.text).join('\n\n')
const response = await turn.wrapLLM(
async () => {
return await openai.chat.completions.create({
model: 'gpt-4o',
messages: [
{ role: 'system', content: `Context:\n${context}` },
{ role: 'user', content: userMessage }
],
})
},
{ model: 'gpt-4o', prompt_id: 'rag_v1' }
)
turn.setMessages([
{ role: 'user', content: userMessage },
{ role: 'assistant', content: response.choices[0].message.content || '' }
])
turn.annotate({
contextLength: context.length,
documentsRetrieved: retrievalResults.length
})
} finally {
await turn.finish()
}Framework Integration
React Integration
Context provider pattern:
// LuminaProvider.tsx
import { createContext, useContext, useEffect, useState } from 'react'
import { Lumina } from 'luminasdk'
import type { SessionCtx } from 'luminasdk'
const LuminaContext = createContext<{ session: SessionCtx | null }>({ session: null })
export function LuminaProvider({ children }: { children: React.ReactNode }) {
const [session, setSession] = useState<SessionCtx | null>(null)
useEffect(() => {
// Initialize on mount
Lumina.init({
endpoint: process.env.NEXT_PUBLIC_LUMINA_ENDPOINT!,
writeKey: process.env.NEXT_PUBLIC_LUMINA_KEY!,
captureTranscript: CaptureTranscript.Full,
uiAnalytics: createDummyProvider()
})
// Start session
Lumina.session.start().then(setSession)
}, [])
return (
<LuminaContext.Provider value={{ session }}>
{children}
</LuminaContext.Provider>
)
}
export const useLumina = () => useContext(LuminaContext)Usage in components:
// ChatComponent.tsx
import { useLumina } from './LuminaProvider'
export function ChatComponent() {
const { session } = useLumina()
const [messages, setMessages] = useState([])
async function sendMessage(text: string) {
if (!session) return
const turn = session.turn()
try {
const response = await turn.wrapLLM(
async () => await callLLM(text),
{ model: 'gpt-4o', prompt_id: 'chat' }
)
turn.setMessages([
{ role: 'user', content: text },
{ role: 'assistant', content: response }
])
setMessages(prev => [...prev,
{ role: 'user', content: text },
{ role: 'assistant', content: response }
])
} finally {
await turn.finish()
}
}
return <div>{/* Chat UI */}</div>
}Next.js Integration
API route wrapper:
// app/api/chat/route.ts
import { Lumina } from 'luminasdk'
import { NextRequest, NextResponse } from 'next/server'
// Initialize once at module level
Lumina.init({
endpoint: process.env.LUMINA_ENDPOINT!,
writeKey: process.env.LUMINA_WRITE_KEY!,
captureTranscript: CaptureTranscript.Masked,
maskFn: (text) => text.replace(/\b[\w.-]+@[\w.-]+\.\w{2,}\b/g, '[EMAIL]')
})
export async function POST(req: NextRequest) {
const { message, sessionId } = await req.json()
// Get or create session
const session = await Lumina.session.start()
const turn = session.turn()
try {
const response = await turn.wrapLLM(
async () => {
return await openai.chat.completions.create({
model: 'gpt-4o',
messages: [{ role: 'user', content: message }],
})
},
{ model: 'gpt-4o', prompt_id: 'api_chat' }
)
const assistantMessage = response.choices[0].message.content || ''
turn.setMessages([
{ role: 'user', content: message },
{ role: 'assistant', content: assistantMessage }
])
await turn.finish()
return NextResponse.json({
message: assistantMessage,
sessionId: session.sessionId
})
} catch (error) {
await turn.finish()
return NextResponse.json({ error: error.message }, { status: 500 })
}
}Express.js Integration
Middleware pattern:
// middleware/lumina.ts
import { Request, Response, NextFunction } from 'express'
import { Lumina } from 'luminasdk'
// Initialize once
Lumina.init({
endpoint: process.env.LUMINA_ENDPOINT!,
writeKey: process.env.LUMINA_WRITE_KEY!,
captureTranscript: CaptureTranscript.Full
})
export async function luminaMiddleware(
req: Request,
res: Response,
next: NextFunction
) {
// Attach session to request
req.luminaSession = await Lumina.session.start()
// Identify user if authenticated
if (req.user) {
await Lumina.identify(req.user.id, {
email: req.user.email,
name: req.user.name
})
}
next()
}
// Route handler
app.post('/api/chat', luminaMiddleware, async (req, res) => {
const turn = req.luminaSession.turn()
try {
const response = await turn.wrapLLM(
async () => await callLLM(req.body.message),
{ model: 'gpt-4o', prompt_id: 'chat' }
)
turn.setMessages([
{ role: 'user', content: req.body.message },
{ role: 'assistant', content: response }
])
res.json({ response })
} finally {
await turn.finish()
}
})Performance Optimization
Minimize Network Overhead
Batch configuration:
Lumina.init({
// Increase buffer size for high-volume apps
maxBatchBytes: 500_000, // 500KB (default: 100KB)
// Reduce flush frequency for low-latency requirements
flushIntervalMs: 2000, // 2s (default: 5s)
// Adjust sampling for high-traffic apps
sampling: {
turn: 1.0, // Always capture turns
uiEvent: 0.1, // 10% of UI events
span: 0.5 // 50% of spans
}
})Async Finish for Better UX
Non-blocking finish calls:
// ✅ Good: Don't block user response on SDK flush
async function handleMessage(text: string) {
const turn = session.turn()
const response = await turn.wrapLLM(
async () => await callLLM(text),
{ model: 'gpt-4o', prompt_id: 'chat' }
)
turn.setMessages([
{ role: 'user', content: text },
{ role: 'assistant', content: response }
])
// Return immediately to user
const userResponse = { message: response }
// Finish in background (don't await)
turn.finish().catch(err => console.error('Lumina flush failed:', err))
return userResponse
}Reduce Payload Size
Selective transcript capture:
// Only capture important turns
const turn = session.turn()
if (shouldCapture(userMessage)) {
turn.setMessages([
{ role: 'user', content: userMessage },
{ role: 'assistant', content: response }
])
} else {
// Still track metadata without transcript
turn.annotate({ transcriptOmitted: true })
}Common Pitfalls
Pitfall 1: Not Calling finish()
Problem:
// ❌ Events never sent
const turn = session.turn()
await turn.wrapLLM(/* ... */)
// Forgot to call finish() - data lost!Solution:
// ✅ Always use try/finally
const turn = session.turn()
try {
await turn.wrapLLM(/* ... */)
} finally {
await turn.finish() // Guaranteed to execute
}Pitfall 2: Creating Too Many Sessions
Problem:
// ❌ New session for every turn
for (const message of messages) {
const session = await Lumina.session.start() // Wrong!
const turn = session.turn()
// ...
}Solution:
// ✅ One session for entire conversation
const session = await Lumina.session.start()
for (const message of messages) {
const turn = session.turn()
// ...
}Pitfall 3: Blocking on Flush
Problem:
// ❌ Awaiting finish() adds latency to user response
const response = await generateResponse()
await turn.finish() // User waits for network flush
return responseSolution:
// ✅ Flush asynchronously
const response = await generateResponse()
turn.finish() // Don't await
return responsePitfall 4: Not Handling Errors
Problem:
// ❌ SDK errors crash the app
await Lumina.identify(userId)Solution:
// ✅ Graceful degradation
try {
await Lumina.identify(userId)
} catch (err) {
console.error('Lumina identify failed:', err)
// App continues working
}Testing Strategies
Unit Testing
Mock the SDK:
// __mocks__/luminasdk.ts
export const Lumina = {
init: jest.fn(),
session: {
start: jest.fn(() => ({
turn: jest.fn(() => ({
wrapLLM: jest.fn((fn) => fn()),
setMessages: jest.fn(),
annotate: jest.fn(),
finish: jest.fn(),
}))
}))
},
identify: jest.fn(),
reset: jest.fn(),
getDistinctId: jest.fn(() => 'test-user'),
isAnonymous: jest.fn(() => false),
}Test with mock:
import { Lumina } from 'luminasdk'
jest.mock('luminasdk')
test('tracks LLM call', async () => {
const session = await Lumina.session.start()
const turn = session.turn()
await myFunction(turn)
expect(turn.wrapLLM).toHaveBeenCalled()
expect(turn.finish).toHaveBeenCalled()
})Integration Testing
Use test environment:
// test-setup.ts
import { Lumina, CaptureTranscript } from 'luminasdk'
Lumina.init({
endpoint: process.env.TEST_LUMINA_ENDPOINT || 'http://localhost:8080',
writeKey: process.env.TEST_LUMINA_KEY || 'test_key',
captureTranscript: CaptureTranscript.None, // Don't send transcripts in tests
flushIntervalMs: 100, // Flush quickly for tests
})Troubleshooting
SDK Not Sending Events
Check initialization:
console.log('Lumina initialized:', Lumina._isInitialized)Verify endpoint:
curl -X POST http://your-endpoint/collect/sdk/events \
-H "X-Api-Key: your-key" \
-H "Content-Type: application/x-ndjson" \
-d '{"source":"test","name":"ping"}'Enable debug logging:
Lumina.init({
// ... config
debug: true // Logs all SDK operations
})Events Not Appearing in Dashboard
Common causes:
- Wrong API key
- Wrong project ID
- Events not flushed yet (wait 5s or call manual flush)
- Workers not running on ingest server
Manual flush:
await Lumina.flush()License
MIT
Support
For issues, questions, or feature requests:
- GitHub Issues: github.com/groundedintelligence/sdk/issues
- Email: [email protected]
- Documentation: docs.groundedintelligence.ai
What's Next?
This SDK is the foundation for Grounded Intelligence — a platform that actively learns from every user interaction across every data stream.
As we expand integrations beyond session replays (customer support, observability, payment events, user feedback), your product will:
- Get hyper-personalized with dynamic content and experiences that adapt to each user's behavior
- Get continuously smarter as your AI models learn from production signals daily — grounded in real usage, not synthetic benchmarks
If this resonates, we'd love to talk. Reach out at the links above.
