Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
ab64872
Add spec: observability integration (PostHog + Langfuse + Sentry)
twn-lloyd Apr 18, 2026
8059e83
docs(observability): add implementation plan for PostHog/Langfuse/Sen…
twn-lloyd Apr 18, 2026
ddf1922
feat(observability): scaffold workspace package
twn-lloyd Apr 18, 2026
dec5c7f
feat(observability): module metadata, ACL, and integration definitions
twn-lloyd Apr 18, 2026
b4116ed
fix(observability): coerce tracesSampleRate, drop orphan validator fi…
twn-lloyd Apr 18, 2026
75f6333
feat(observability): register module in mercato app
twn-lloyd Apr 18, 2026
9606da6
feat(observability): redaction helper with tests
twn-lloyd Apr 18, 2026
98ff117
feat(observability): per-tenant config resolver with cache
twn-lloyd Apr 18, 2026
5bc8ce5
feat(observability): event mapper with allow/denylist and PII scrubbing
twn-lloyd Apr 18, 2026
72c3110
feat(observability): PostHog client factory and health check
twn-lloyd Apr 18, 2026
9b9a61f
feat(observability): wildcard subscriber forwards events to PostHog
twn-lloyd Apr 18, 2026
422a014
feat(observability): Sentry server init with tenant scope helper
twn-lloyd Apr 18, 2026
8e8a800
feat(observability): wire Sentry instrumentation into mercato app
twn-lloyd Apr 18, 2026
7ecc0f5
feat(observability): Sentry tenant-scope interceptor
twn-lloyd Apr 18, 2026
f38d784
feat(observability): Sentry health check
twn-lloyd Apr 18, 2026
9d69ad1
feat(ai-assistant): llmTracer DI token with no-op default
twn-lloyd Apr 18, 2026
39fd7bd
feat(ai-assistant): wrap routing generateObject with llmTracer
twn-lloyd Apr 18, 2026
165e567
feat(observability): Langfuse client, LLM tracer, and health check
twn-lloyd Apr 18, 2026
3ef52f8
feat(observability): DI registration overrides llmTracer when Langfus…
twn-lloyd Apr 18, 2026
ffb8e05
feat(observability): client-config API endpoint
twn-lloyd Apr 18, 2026
f00cdc0
feat(observability): admin + portal shell browser bootstrap widgets
twn-lloyd Apr 18, 2026
4a020ef
feat(observability): env preset helper
twn-lloyd Apr 18, 2026
33f8e1a
feat(observability): setup config with default role features and pres…
twn-lloyd Apr 18, 2026
5280cc6
feat(observability): CLI commands configure-from-env and test-capture
twn-lloyd Apr 18, 2026
122cdf8
feat(observability): i18n strings (en, pl)
twn-lloyd Apr 18, 2026
11a65ca
docs(observability): README and release notes
twn-lloyd Apr 18, 2026
c83ea6a
test(observability): lifecycle integration tests
twn-lloyd Apr 18, 2026
c7f9bf8
test(observability): PostHog wiring roundtrip integration test
twn-lloyd Apr 18, 2026
49a0e82
test(ai-assistant): verify decoupling from observability package
twn-lloyd Apr 18, 2026
e6ed6d5
fix(observability): use disable_session_recording PostHog option
twn-lloyd Apr 18, 2026
1906e98
fix(observability): restore build and close rollup CVE
twn-lloyd Apr 18, 2026
960ec1e
fix(deps): bump rollup resolution to 4.60.1 for GHSA-mw96-cpmx-2vgc
twn-lloyd Apr 18, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2,760 changes: 2,760 additions & 0 deletions .ai/plans/2026-04-18-observability-integration-posthog-langfuse-sentry.md

Large diffs are not rendered by default.

Large diffs are not rendered by default.

8 changes: 8 additions & 0 deletions RELEASE_NOTES.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
# Release Notes - Open Mercato (Unreleased)

## Added

- **`@open-mercato/observability` package** — PostHog, Langfuse, and Sentry as Integration Marketplace providers with cloud/self-hosted parity. Includes server event forwarding, admin + portal browser instrumentation, LLM tracing for the AI assistant via an additive `llmTracer` DI token, env-preset bootstrap, and per-tenant PII scrubbing.

---

# Release Notes - Open Mercato v0.4.3

**Date:** April 11, 2026
Expand Down
8 changes: 8 additions & 0 deletions apps/mercato/instrumentation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export async function register() {
if (process.env.NEXT_RUNTIME === 'nodejs') {
const { registerSentryInstrumentation } = await import(
'@open-mercato/observability/modules/observability/lib/sentry-instrumentation'
)
registerSentryInstrumentation()
}
}
1 change: 1 addition & 0 deletions apps/mercato/src/modules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ export const enabledModules: ModuleEntry[] = [
{ id: 'sync_akeneo', from: '@open-mercato/sync-akeneo' },
{ id: 'shipping_carriers', from: '@open-mercato/core' },
{ id: 'webhooks', from: '@open-mercato/webhooks' },
{ id: 'observability', from: '@open-mercato/observability' },
{ id: 'customer_accounts', from: '@open-mercato/core' },
{ id: 'portal', from: '@open-mercato/core' },
{ id: 'example', from: '@app' },
Expand Down
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@
"path-to-regexp": "0.1.13",
"picomatch": "2.3.2",
"protobufjs": "7.5.5",
"yaml": "2.8.3"
"yaml": "2.8.3",
"rollup": "4.60.1"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { noopTracer } from '../lib/llm-tracer-types'

describe('noopTracer', () => {
it('invokes fn and returns its result', async () => {
const result = await noopTracer.traceLLM({ name: 'x', input: {} }, async (ctx) => {
ctx.recordGeneration({ name: 'gen', input: {} })
return 42
})
expect(result).toBe(42)
})

it('propagates errors', async () => {
await expect(
noopTracer.traceLLM({ name: 'x', input: {} }, async () => { throw new Error('boom') })
).rejects.toThrow('boom')
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { noopTracer } from '../lib/llm-tracer-types'

describe('ai-assistant without observability', () => {
it('noopTracer invokes fn and returns value unchanged', async () => {
const result = await noopTracer.traceLLM(
{ name: 'unit.test', input: { q: 1 } },
async () => ({ answer: 42 }),
)
expect(result).toEqual({ answer: 42 })
})

it('noopTracer accepts ctx.recordGeneration calls without side effects', async () => {
await noopTracer.traceLLM({ name: 'unit.test', input: {} }, async (ctx) => {
expect(() =>
ctx.recordGeneration({ name: 'generation', input: {}, output: {} }),
).not.toThrow()
return 'ok'
})
})

it('noopTracer propagates errors from the wrapped function', async () => {
const boom = new Error('boom')
await expect(
noopTracer.traceLLM({ name: 'unit.test', input: {} }, async () => {
throw boom
}),
).rejects.toBe(boom)
})
})
39 changes: 33 additions & 6 deletions packages/ai-assistant/src/modules/ai_assistant/api/route/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import {
isProviderConfigured,
type ChatProviderId,
} from '../../lib/chat-config'
import type { LLMTracer } from '../../lib/llm-tracer-types'
import { noopTracer } from '../../lib/llm-tracer-types'

export const openApi: OpenApiRouteDoc = {
tag: 'AI Assistant',
Expand Down Expand Up @@ -138,10 +140,13 @@ export async function POST(req: NextRequest) {

console.log('[AI Route] Calling generateObject with', modelWithProvider)

const result = await generateObject({
model,
schema: RouteResultSchema,
prompt: `You are a routing assistant. Given a user query, determine if they want to use a specific tool or have a general conversation.
let tracer: LLMTracer = noopTracer
try {
tracer = container.resolve<LLMTracer>('llmTracer')
} catch {
/* tracer not registered; use no-op */
}
const prompt = `You are a routing assistant. Given a user query, determine if they want to use a specific tool or have a general conversation.

Available tools:
${toolList}
Expand All @@ -152,8 +157,30 @@ Respond with:
- intent: "tool" if user wants to perform an action with a specific tool, "general_chat" otherwise
- toolName: the exact tool name if intent is "tool"
- confidence: 0-1 how confident you are
- reasoning: brief explanation`,
})
- reasoning: brief explanation`

const result = await tracer.traceLLM(
{
name: 'ai-assistant.route',
input: { query, availableTools },
tenantId: auth.tenantId ?? undefined,
userId: auth.userId ?? undefined,
},
async (ctx) => {
const res = await generateObject({
model,
schema: RouteResultSchema,
prompt,
})
ctx.recordGeneration({
name: 'route',
model: modelWithProvider,
input: { query, availableTools },
output: res.object,
})
return res
}
)

console.log('[AI Route] Result:', result.object)
return NextResponse.json(result.object)
Expand Down
2 changes: 2 additions & 0 deletions packages/ai-assistant/src/modules/ai_assistant/di.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { asValue } from 'awilix'
import type { AwilixContainer } from 'awilix'
import { toolRegistry } from './lib/tool-registry'
import { noopTracer } from './lib/llm-tracer-types'

export function register(container: AwilixContainer): void {
container.register({
mcpToolRegistry: asValue(toolRegistry),
llmTracer: asValue(noopTracer),
})
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
export type LLMTraceInput = {
name: string
input: unknown
metadata?: Record<string, unknown>
userId?: string
tenantId?: string
}

export type LLMTraceContext = {
recordGeneration(opts: {
name: string
model?: string
input: unknown
output?: unknown
usage?: { promptTokens?: number; completionTokens?: number; totalTokens?: number }
}): void
}

export interface LLMTracer {
traceLLM<T>(opts: LLMTraceInput, fn: (ctx: LLMTraceContext) => Promise<T>): Promise<T>
}

export const noopTracer: LLMTracer = {
async traceLLM(_opts, fn) {
const ctx: LLMTraceContext = { recordGeneration: () => undefined }
return fn(ctx)
},
}
96 changes: 96 additions & 0 deletions packages/observability/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# @open-mercato/observability

Product analytics (PostHog), LLM tracing (Langfuse), and error monitoring (Sentry) as open-mercato Integration Marketplace providers. Works against cloud or self-hosted deployments of all three tools.

## Installation

Included in the mercato app by default. Register in your custom app via `apps/<app>/src/modules.ts`:

```ts
export const modules = [
// ...
'@open-mercato/observability',
]
```

## Configuration

### Via admin UI

Navigate to `/backend/integrations`, pick PostHog, Langfuse, or Sentry, fill in credentials, and enable.

### Via environment variables

| Variable | Purpose | Default |
|---|---|---|
| `OM_INTEGRATION_POSTHOG_PROJECT_KEY` | PostHog project API key | — |
| `OM_INTEGRATION_POSTHOG_HOST` | PostHog host (cloud or self-hosted) | `https://us.i.posthog.com` |
| `OM_INTEGRATION_LANGFUSE_PUBLIC_KEY` | Langfuse public key | — |
| `OM_INTEGRATION_LANGFUSE_SECRET_KEY` | Langfuse secret key | — |
| `OM_INTEGRATION_LANGFUSE_HOST` | Langfuse host | `https://cloud.langfuse.com` |
| `OM_INTEGRATION_SENTRY_DSN` | Sentry DSN (encodes host) | — |
| `OM_INTEGRATION_SENTRY_ENVIRONMENT` | Sentry environment tag | `NODE_ENV` |
| `OM_INTEGRATION_SENTRY_TRACES_SAMPLE_RATE` | Transaction sample rate | `0.1` |

Env variables apply on tenant bootstrap and can be re-applied with:

```bash
yarn mercato observability configure-from-env --tenant <tenantId> --org <organizationId>
```

Emit a synthetic PostHog event to verify forwarding:

```bash
yarn mercato observability test-capture --tenant <tenantId>
```

### Self-hosted deployments

All three providers accept a host/DSN pointing at your self-hosted deployment — no code change required. Set the `host` credential (PostHog, Langfuse) or the DSN domain (Sentry) accordingly.

## Data forwarded

### PostHog

A wildcard subscriber forwards tenant-scoped events matching the default allowlist:

- `auth.user.loggedIn`
- `sales.order.created`
- `sales.quote.accepted`
- `catalog.product.created`
- `customers.person.created`
- `integrations.state.updated`
- `workflows.instance.completed`

Default denylist blocks events with substrings `credentials`, `secret`, `password`, `integrations.log`.

Customize per-tenant by editing the integration's `config` (`allowlist: string[]`, `denylist: string[]`, `redactionKeys: string[]`).

### Langfuse

Traces all LLM calls made by the open-mercato AI assistant. Each trace records input, output, tokens, latency, model, and tenant/user metadata.

### Sentry

Captures server and browser errors/performance. Server DSN is process-global (see multi-tenant caveat below). Browser DSN is per-tenant via `/api/observability/client-config`.

## Security

- All credentials encrypted at rest via the integrations module's encryption service.
- All forwarded payloads pass through a PII scrubber (keys matching `password|secret|token|apiKey|privateKey|authorization|cookie|sessionId|creditCard|cvv|ssn|dsn` → `[REDACTED]`).
- Strings larger than 8KB are truncated before forwarding.
- Opt-in redaction of additional keys per tenant via `redactionKeys`.

## Multi-tenant Sentry caveat

Sentry's Node SDK is process-global. For multi-tenant SaaS deployments:

- Preferred: run separate Sentry projects per tenant with a reverse proxy.
- Acceptable: use a single project and filter by the `tenant_id` tag (automatically applied to every error).
- Browser-side Sentry is always per-tenant.

## Testing

```bash
cd packages/observability && yarn test
```
70 changes: 70 additions & 0 deletions packages/observability/build.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import * as esbuild from 'esbuild'
import { glob } from 'glob'
import { readFileSync, writeFileSync, existsSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'

const __dirname = dirname(fileURLToPath(import.meta.url))

const entryPoints = await glob('src/**/*.{ts,tsx}', {
cwd: __dirname,
ignore: ['**/__tests__/**', '**/*.test.ts', '**/*.test.tsx'],
absolute: true,
})

if (entryPoints.length === 0) {
console.error('No entry points found!')
process.exit(1)
}

console.log(`Found ${entryPoints.length} entry points`)

const addJsExtension = {
name: 'add-js-extension',
setup(build) {
build.onEnd(async (result) => {
if (result.errors.length > 0) return
const outputFiles = await glob('dist/**/*.js', { cwd: __dirname, absolute: true })
for (const file of outputFiles) {
const fileDir = dirname(file)
let content = readFileSync(file, 'utf-8')
content = content.replace(
/from\s+["'](\.[^"']+)["']/g,
(match, path) => {
if (path.endsWith('.js') || path.endsWith('.json')) return match
const resolvedPath = join(fileDir, path)
if (existsSync(resolvedPath) && existsSync(join(resolvedPath, 'index.js'))) {
return `from "${path}/index.js"`
}
return `from "${path}.js"`
}
)
content = content.replace(
/import\s*\(\s*["'](\.[^"']+)["']\s*\)/g,
(match, path) => {
if (path.endsWith('.js') || path.endsWith('.json')) return match
const resolvedPath = join(fileDir, path)
if (existsSync(resolvedPath) && existsSync(join(resolvedPath, 'index.js'))) {
return `import("${path}/index.js")`
}
return `import("${path}.js")`
}
)
writeFileSync(file, content)
}
})
}
}

await esbuild.build({
entryPoints,
outdir: 'dist',
format: 'esm',
platform: 'node',
target: 'node18',
sourcemap: true,
jsx: 'automatic',
plugins: [addJsExtension],
})

console.log('gateway-stripe built successfully')
20 changes: 20 additions & 0 deletions packages/observability/jest.config.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/** @type {import('jest').Config} */
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
watchman: false,
rootDir: '.',
moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'],
transform: {
'^.+\\.(t|j)sx?$': [
'ts-jest',
{
tsconfig: {
jsx: 'react-jsx',
},
},
],
},
testMatch: ['<rootDir>/src/**/__tests__/**/*.test.(ts|tsx)'],
passWithNoTests: true,
}
Loading
Loading