Guides
Best Practices

Best Practices

Follow these best practices to get the most out of the Grounded Intelligence SDK.

1. Initialize Once

Always initialize at the application entry point, never in individual components or on every request:

// ✅ Good: Initialize at app startup
// main.ts or App.tsx
Lumina.init({ /* ... */ })
 
// ❌ Bad: Initialize on every request
function handleRequest() {
  Lumina.init({ /* ... */ })  // Don't do this
}

Why: Initialization sets up global state, event buffers, and provider connections. Reinitializing can cause:

  • Lost events
  • Multiple flush intervals
  • Provider connection conflicts

2. Use One Session Per Conversation

// ✅ Good: One session per chat thread
const session = await Lumina.session.start()
 
// User sends first message
const turn1 = session.turn()
await turn1.wrapLLM(/* ... */)
await turn1.finish()
 
// User sends follow-up in same conversation
const turn2 = session.turn()
await turn2.wrapLLM(/* ... */)
await turn2.finish()
 
// ❌ Bad: New session for every turn
const session1 = await Lumina.session.start()
const turn1 = session1.turn()
await turn1.finish()
 
const session2 = await Lumina.session.start()  // Don't do this
const turn2 = session2.turn()
await turn2.finish()

Why: Sessions represent a conversation thread and share a trace ID. Creating a new session for every turn:

  • Breaks conversation continuity
  • Makes analytics harder
  • Loses context across turns

Note: In most real-world apps, session.start() happens once (e.g., when a chat opens), and session.turn() is called each time the user sends a message. You don't typically iterate through messages in a loop - each turn happens as the user interacts.


3. Always Call finish()

Use try/finally blocks to guarantee finish() is called:

// ✅ Good: Use try/finally to guarantee finish
const turn = session.turn()
try {
  await turn.wrapLLM(/* ... */)
  turn.setMessages(/* ... */)
} finally {
  await turn.finish()  // Always executes, even on error
}
 
// ❌ Bad: finish() might not be called on error
const turn = session.turn()
await turn.wrapLLM(/* ... */)
await turn.finish()  // Skipped if wrapLLM throws

Why: finish() sends events to the ingest server. Without it:

  • Events are never sent
  • Analytics data is lost
  • Memory leaks (events stay in buffer)

4. Identify Users Early

Identify users immediately after authentication:

// ✅ Good: Identify as soon as user logs in
async function handleLogin(userId: string, userInfo: any) {
  await Lumina.identify(userId, {
    email: userInfo.email,
    name: userInfo.name,
    plan: userInfo.plan
  })
}
 
// ❌ Bad: Never identifying users
// Anonymous tracking is okay, but identified gives richer analytics

Why: Early identification ensures:

  • All subsequent events are linked to the user
  • Anonymous → identified aliasing works correctly
  • Better user journey analytics

5. Mask PII

Always use masked mode in production with a comprehensive maskFn:

// ✅ Good: Use Masked mode with custom maskFn
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
  },
})
 
// ❌ Bad: No masking in production
Lumina.init({
  captureTranscript: CaptureTranscript.Full,  // Risky in production
})

Why: PII exposure can lead to:

  • Privacy violations
  • GDPR/CCPA compliance issues
  • Security breaches

6. Don't Block on Flush

Don't block user responses on SDK flush operations:

// ✅ 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
}
 
// ❌ Bad: Awaiting finish() adds latency to user response
async function handleMessage(text: string) {
  const turn = session.turn()
  const response = await generateResponse()
  await turn.finish()  // User waits for network flush
  return response
}

Why: Awaiting finish() adds network latency to the critical path. Let it run asynchronously.


7. Use Environment Variables

Store sensitive keys in environment variables, never hardcode:

# .env.local
NEXT_PUBLIC_LUMINA_ENDPOINT=https://ingest.yourdomain.com
NEXT_PUBLIC_LUMINA_WRITE_KEY=gi_xxxxx
NEXT_PUBLIC_POSTHOG_KEY=phc_xxxxx
// ✅ Good: Use environment variables
Lumina.init({
  endpoint: process.env.NEXT_PUBLIC_LUMINA_ENDPOINT!,
  writeKey: process.env.NEXT_PUBLIC_LUMINA_WRITE_KEY!,
})
 
// ❌ Bad: Hardcoded keys
Lumina.init({
  endpoint: 'https://ingest.example.com',
  writeKey: 'gi_1234567890abcdef',  // Don't do this
})

8. Configure for Environment

Use different settings for dev/staging/prod:

const isDev = process.env.NODE_ENV === 'development'
 
Lumina.init({
  endpoint: isDev
    ? 'http://localhost:8080'
    : process.env.NEXT_PUBLIC_LUMINA_ENDPOINT!,
  writeKey: process.env.NEXT_PUBLIC_LUMINA_WRITE_KEY!,
  captureTranscript: isDev
    ? CaptureTranscript.Full
    : CaptureTranscript.Masked,
  flushIntervalMs: isDev ? 1000 : 5000,  // More frequent in dev
})

9. Annotate with Context

Add relevant context to turns for better analytics:

// ✅ Good: Rich annotations
turn.annotate({
  ui_session_id: posthog.get_session_id(),
  ui_replay_url: posthog.get_replay_url(),
  user_tier: 'pro',
  feature_flag: 'new_ui_enabled',
  ab_test_variant: 'variant_b',
})
 
// ❌ Bad: No context
await turn.finish()  // Minimal value for analytics

Why: Annotations enable:

  • Correlation with UI events
  • Segmentation by user properties
  • A/B test analysis

10. Minimize Payload Size

Be selective about what you capture:

// ✅ Good: Selective transcript capture
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 })
}
 
// ✅ Good: Truncate large context in retrieval
turn.addRetrieval({
  source: 'docs',
  query: userMessage,
  results: retrievalResults.map(r => ({
    id: r.id,
    score: r.score,
    content: r.text.substring(0, 200)  // Truncate
  }))
})
 
// ❌ Bad: Store entire documents
turn.addRetrieval({
  results: retrievalResults.map(r => ({
    content: r.text  // Could be 10KB+ per result
  }))
})

Why: Large payloads:

  • Increase network overhead
  • Slow down analytics queries
  • Increase storage costs

Common Pitfalls

Pitfall 1: Not Calling finish()

// ❌ Events never sent
const turn = session.turn()
await turn.wrapLLM(/* ... */)
// Forgot to call finish() - data lost!

Solution: Use try/finally (see #3 above)


Pitfall 2: Creating Too Many Sessions

// ❌ New session for every turn
for (const message of messages) {
  const session = await Lumina.session.start()  // Wrong!
  const turn = session.turn()
  // ...
}

Solution: One session per conversation (see #2 above)


Pitfall 3: Blocking on Flush

// ❌ Awaiting finish() adds latency
const response = await generateResponse()
await turn.finish()  // User waits for network flush
return response

Solution: Flush asynchronously (see #6 above)


Pitfall 4: Not Handling SDK Errors

// ❌ SDK errors crash the app
await Lumina.identify(userId)

Solution: Graceful degradation

// ✅ Graceful degradation
try {
  await Lumina.identify(userId)
} catch (err) {
  console.error('Lumina identify failed:', err)
  // App continues working
}

Performance Tips

1. Minimize Network Overhead

Adjust batch configuration for your traffic:

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

2. Reduce Payload Size

Only capture important turns:

const turn = session.turn()
 
if (isImportantTurn(userMessage)) {
  turn.setMessages([/* ... */])
} else {
  turn.annotate({ transcriptOmitted: true })
}

3. Sample High-Traffic Events

For very high-traffic applications:

Lumina.init({
  sampling: {
    turn: 1.0,      // Always capture turns
    uiEvent: 0.1,   // 10% of UI events
    span: 0.5       // 50% of spans
  }
})

Next Steps