Skip to content

Funnel Analysis

Funnel analysis allows you to track user journeys through a sequence of events, measuring conversion rates and time-to-convert between steps. Drizzle Cube provides server-side funnel execution for accurate temporal ordering and comprehensive metrics.

Funnel queries differ from standard queries in several important ways:

AspectStandard QueryFunnel Query
Data StructureSingle aggregation resultSequential step progression
ExecutionSingle SQL queryCTE-based temporal analysis
OutputRaw measures/dimensionsConversion rates, time metrics
UI ModeQuery/Multi-Query tabsDedicated Steps + Display tabs

The binding key is the dimension that links events across steps - typically a user ID, session ID, or transaction ID. This determines which entity you’re tracking through the funnel.

Example: Tracking PR lifecycle
Binding Key: PREvents.prNumber
Step 1: PR Created → 100 PRs
Step 2: Review Started → 85 PRs (85% conversion)
Step 3: Approved → 75 PRs (88% conversion from step 2)
Step 4: Merged → 70 PRs (93% conversion from step 3)

The time dimension is required for temporal ordering - it ensures steps occur in the correct sequence (step 2 must happen after step 1).

Each step in a funnel represents a milestone event. Steps are defined by:

  • Name: Display label (e.g., “PR Created”, “Review Started”)
  • Filters: Dimension filters that identify the event (e.g., eventType = 'created')
  • Time Window (optional): Maximum time allowed from previous step

To enable funnel analysis, mark cubes with eventStream metadata. This tells the Analysis Builder which cubes can be used for funnel analysis and auto-populates configuration.

import { defineCube } from 'drizzle-cube/server';
import { eq } from 'drizzle-orm';
import { prEvents } from './schema';
export const prEventsCube = defineCube('PREvents', {
title: 'PR Events',
description: 'Pull request lifecycle events for funnel analysis',
sql: (ctx) => ({
from: prEvents,
where: eq(prEvents.organisationId, ctx.securityContext.organisationId)
}),
dimensions: {
id: {
name: 'id',
title: 'Event ID',
type: 'number',
sql: prEvents.id,
primaryKey: true
},
prNumber: {
name: 'prNumber',
title: 'PR Number',
type: 'number',
sql: prEvents.prNumber
},
eventType: {
name: 'eventType',
title: 'Event Type',
type: 'string',
sql: prEvents.eventType
},
timestamp: {
name: 'timestamp',
title: 'Event Timestamp',
type: 'time',
sql: prEvents.timestamp
}
},
measures: {
count: {
name: 'count',
title: 'Event Count',
type: 'count',
sql: prEvents.id
},
uniquePRs: {
name: 'uniquePRs',
title: 'Unique PRs',
type: 'countDistinct',
sql: prEvents.prNumber
}
},
// Event stream metadata enables funnel analysis
meta: {
eventStream: {
bindingKey: 'PREvents.prNumber', // Entity identifier
timeDimension: 'PREvents.timestamp' // Event ordering
}
}
});
PropertyTypeDescription
bindingKeystringFully qualified dimension name (e.g., CubeName.dimensionName) that uniquely identifies entities through the funnel
timeDimensionstringFully qualified time dimension name used for temporal ordering

When a cube has eventStream metadata:

  1. It appears in the funnel cube selector
  2. Selecting it auto-populates the binding key and time dimension
  3. Users can immediately start adding steps without manual configuration

In the Analysis Builder, use the analysis type selector to switch between modes:

  • Query: Single query mode (standard analytics)
  • Multi: Multiple queries with merge strategies
  • Funnel: Funnel analysis mode
  1. Select Cube: Choose an event stream cube from the dropdown
  2. Binding Key: Auto-populated from cube metadata (can be changed)
  3. Time Dimension: Auto-populated from cube metadata (can be changed)
  4. Add Steps: Define each step with filters

Each step requires:

FieldRequiredDescription
NameYesDisplay label for the step (click to edit)
FiltersYesDimension filters identifying this event
Time WindowNoMaximum time from previous step (ISO 8601 duration)

Time windows use ISO 8601 duration format:

DurationMeaning
PT1H1 hour
PT6H6 hours
PT24H24 hours
P1D1 day
P3D3 days
P7D7 days
P14D14 days
P30D30 days
P90D90 days

Example: If Step 2 has a time window of P7D, the entity must complete Step 2 within 7 days of completing Step 1 to count as converted.

{
funnel: {
bindingKey: "PREvents.prNumber",
timeDimension: "PREvents.timestamp",
steps: [
{
name: "PR Created",
filter: {
member: "PREvents.eventType",
operator: "equals",
values: ["created"]
}
},
{
name: "Review Started",
filter: {
member: "PREvents.eventType",
operator: "equals",
values: ["review_started"]
},
timeToConvert: "P3D" // Within 3 days
},
{
name: "Approved",
filter: {
member: "PREvents.eventType",
operator: "equals",
values: ["approved"]
},
timeToConvert: "P7D" // Within 7 days
},
{
name: "Merged",
filter: {
member: "PREvents.eventType",
operator: "equals",
values: ["merged"]
},
timeToConvert: "P1D" // Within 1 day
}
],
includeTimeMetrics: true,
globalTimeWindow: "P30D" // All steps within 30 days
}
}
[
{
step: "PR Created",
stepIndex: 0,
count: 1000,
conversionRate: null, // null for first step
cumulativeConversionRate: 1.0,
avgSecondsToConvert: null,
medianSecondsToConvert: null,
p90SecondsToConvert: null
},
{
step: "Review Started",
stepIndex: 1,
count: 850,
conversionRate: 0.85, // 850/1000 = 85%
cumulativeConversionRate: 0.85,
avgSecondsToConvert: 7200, // 2 hours average
medianSecondsToConvert: 3600, // 1 hour median
p90SecondsToConvert: 21600 // 6 hours p90
},
{
step: "Approved",
stepIndex: 2,
count: 680,
conversionRate: 0.8, // 680/850 = 80%
cumulativeConversionRate: 0.68, // 680/1000 = 68%
avgSecondsToConvert: 86400, // 1 day average
medianSecondsToConvert: 43200,
p90SecondsToConvert: 172800
},
{
step: "Merged",
stepIndex: 3,
count: 650,
conversionRate: 0.956, // 650/680 = 95.6%
cumulativeConversionRate: 0.65, // 650/1000 = 65%
avgSecondsToConvert: 14400, // 4 hours average
medianSecondsToConvert: 7200,
p90SecondsToConvert: 43200
}
]

Funnel configurations are persisted using the FunnelAnalysisConfig format. This is part of the broader AnalysisConfig system introduced in v0.3.0.

import type { FunnelAnalysisConfig } from 'drizzle-cube/client'
const funnelConfig: FunnelAnalysisConfig = {
version: 1,
analysisType: 'funnel',
activeView: 'chart',
charts: {
funnel: {
chartType: 'funnel',
chartConfig: {},
displayConfig: {
showPercentages: true,
showTimeMetrics: true
}
}
},
query: {
funnel: {
bindingKey: 'PREvents.prNumber',
timeDimension: 'PREvents.timestamp',
steps: [
{
name: 'PR Created',
filter: {
member: 'PREvents.eventType',
operator: 'equals',
values: ['created']
}
},
{
name: 'Review Started',
filter: {
member: 'PREvents.eventType',
operator: 'equals',
values: ['review_started']
},
timeToConvert: 'P3D'
},
{
name: 'Merged',
filter: {
member: 'PREvents.eventType',
operator: 'equals',
values: ['merged']
},
timeToConvert: 'P7D'
}
],
includeTimeMetrics: true
}
}
}
import { useAnalysisBuilderStore } from 'drizzle-cube/client'
function FunnelPersistence() {
const save = useAnalysisBuilderStore(state => state.save)
const load = useAnalysisBuilderStore(state => state.load)
// Save current funnel config
const handleSave = () => {
const config = save() // Returns FunnelAnalysisConfig when in funnel mode
localStorage.setItem('savedFunnel', JSON.stringify(config))
}
// Load saved funnel
const handleLoad = () => {
const saved = localStorage.getItem('savedFunnel')
if (saved) {
load(JSON.parse(saved)) // Restores funnel state
}
}
}
import { generateShareUrl, parseShareUrl } from 'drizzle-cube/client'
// Generate shareable URL
const shareUrl = generateShareUrl(funnelConfig)
// Result: https://app.com/analysis#share=eJy...
// Parse from URL
const loadedConfig = parseShareUrl()
if (loadedConfig?.analysisType === 'funnel') {
// Handle funnel config
}
import { isFunnelConfig, isValidAnalysisConfig } from 'drizzle-cube/client'
function handleConfig(config: unknown) {
if (!isValidAnalysisConfig(config)) {
console.error('Invalid config')
return
}
if (isFunnelConfig(config)) {
// TypeScript knows config.query is ServerFunnelQuery
const steps = config.query.funnel.steps
}
}

See AnalysisConfig Reference for complete type documentation.

MetricDescription
countNumber of entities that reached this step
conversionRateConversion from previous step (step N count / step N-1 count)
cumulativeConversionRateConversion from first step (step N count / step 1 count)

When includeTimeMetrics: true is set:

MetricDescription
avgSecondsToConvertAverage time from previous step (seconds)
medianSecondsToConvertMedian time from previous step (50th percentile)
p90SecondsToConvert90th percentile time from previous step

Funnel queries are executed as a single SQL query using Common Table Expressions (CTEs):

Each step generates a CTE that finds the first occurrence of that event per entity:

WITH step_0 AS (
SELECT
pr_number AS binding_key,
MIN(timestamp) AS step_time
FROM pr_events
WHERE event_type = 'created'
AND organisation_id = $1
GROUP BY pr_number
),
step_1 AS (
SELECT
pr_number AS binding_key,
MIN(timestamp) AS step_time
FROM pr_events
WHERE event_type = 'review_started'
AND organisation_id = $1
GROUP BY pr_number
)

Steps are joined with temporal constraints:

funnel_joined AS (
SELECT
s0.binding_key,
s0.step_time AS step_0_time,
CASE
WHEN s1.step_time > s0.step_time
AND s1.step_time <= s0.step_time + INTERVAL '3 days'
THEN s1.step_time
ELSE NULL
END AS step_1_time
FROM step_0 s0
LEFT JOIN step_1 s1 ON s0.binding_key = s1.binding_key
)

Final aggregation produces funnel metrics:

funnel_metrics AS (
SELECT
COUNT(*) AS step_0_count,
COUNT(step_1_time) AS step_1_count,
AVG(EXTRACT(EPOCH FROM (step_1_time - step_0_time))) AS step_1_avg_seconds
FROM funnel_joined
)
RequirementDetails
Minimum 2 stepsA funnel must have at least 2 steps
Binding key requiredMust identify the entity being tracked
Time dimension requiredMust have a timestamp for ordering
Single cubeAll steps must use the same cube
  • Dimensions only: Step filters cannot reference measures
  • Equality preferred: While other operators work, equals is most common
  • Multi-value support: Filter values can be arrays for OR logic
  1. Use entity identifiers as binding keys - User ID, session ID, or transaction ID
  2. Ensure time dimension accuracy - Timestamps should reflect when events occurred
  3. Set appropriate time windows - Too short may miss valid conversions; too long may include irrelevant events
  4. Limit step count - While unlimited steps are supported, 3-7 steps provide the clearest insights
  5. Name steps descriptively - Clear names improve chart readability
import { useFunnelQuery } from 'drizzle-cube/client';
function FunnelDisplay() {
const {
data, // FunnelChartData[]
isLoading,
isFetching,
error,
isValid
} = useFunnelQuery({
cube: 'PREvents',
bindingKey: { dimension: 'PREvents.prNumber' },
timeDimension: 'PREvents.timestamp',
steps: [
{
id: '1',
name: 'PR Created',
cube: 'PREvents',
filters: [
{ member: 'PREvents.eventType', operator: 'equals', values: ['created'] }
]
},
{
id: '2',
name: 'Merged',
cube: 'PREvents',
filters: [
{ member: 'PREvents.eventType', operator: 'equals', values: ['merged'] }
],
timeToConvert: 'P7D'
}
],
includeTimeMetrics: true
});
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return <FunnelChart data={data} />;
}
const query = {
funnel: {
bindingKey: 'PREvents.prNumber',
timeDimension: 'PREvents.timestamp',
steps: [
{ name: 'Created', filter: { member: 'PREvents.eventType', operator: 'equals', values: ['created'] } },
{ name: 'Merged', filter: { member: 'PREvents.eventType', operator: 'equals', values: ['merged'] } }
],
includeTimeMetrics: true
}
};
const response = await fetch('/api/cubejs-api/v1/load', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query })
});
const result = await response.json();

The FunnelChart component automatically handles funnel data:

import { FunnelChart } from 'drizzle-cube/client/charts';
function MyFunnel({ data }) {
return (
<FunnelChart
data={data}
displayConfig={{
showLegend: true,
showTooltip: true,
colors: ['#3b82f6', '#10b981', '#f59e0b', '#ef4444']
}}
/>
);
}

The chart displays:

  • Visual funnel shape with step widths proportional to counts
  • Conversion rates between steps
  • Cumulative conversion from first step
  • Time-to-convert metrics in tooltips (when available)
IssueCauseSolution
”No results”No entities complete step 1Check filter conditions match your data
Zero conversionsTime windows too restrictiveIncrease time window durations
Missing time metricsDatabase doesn’t support percentilesUse PostgreSQL or accept null values
”Invalid binding key”Dimension doesn’t existVerify dimension name in cube definition

Use dry-run to see the generated SQL:

const dryRunResult = await fetch('/api/cubejs-api/v1/sql', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ query: funnelQuery })
});
const { sql } = await dryRunResult.json();
console.log(sql);