A comprehensive guide for contributors to the Grafana Metrics Drilldown plugin - a queryless, exploration-focused interface for browsing Prometheus-compatible metrics built with Grafana's Scenes framework.
- Quick Start
- Architecture Overview
- Development Environment
- Core Concepts
- Component Architecture
- State Management
- Event-Driven Patterns
- Testing Strategy
- Contributing Guidelines
- Performance Considerations
- Troubleshooting
- Node.js: 22+ required
- Docker: For local Grafana development server
- Git: For version control
- pnpm: 10.28.2+ required - Install via corepack (recommended) or see pnpm installation guide for other methods:
corepack enable corepack prepare pnpm@10.28.2 --activate
# Clone and setup
git clone <repository-url>
cd metrics-drilldown
pnpm install
# Start development environment
pnpm run server # Start Grafana server (http://localhost:3001)
pnpm run dev # Build plugin in watch mode
# Run tests
pnpm run test # Unit tests with coverage
pnpm run e2e # End-to-end tests# Development
pnpm run dev # Watch mode development
pnpm run server # Docker-based Grafana server (port 3001)
# Testing
pnpm run test # Jest unit tests with coverage
pnpm run tdd # Tests in watch mode
pnpm run e2e # Playwright end-to-end tests
pnpm run e2e:watch # E2E tests with UI mode
# Code Quality
pnpm run lint # Check for lint errors
pnpm run lint:fix # Fix lint errors automatically
pnpm run typecheck # Type checking without compilation
# Build
pnpm run build # Production build
pnpm run analyze # Bundle analysisThe Grafana Metrics Drilldown plugin is a sophisticated app plugin that leverages Grafana's Scenes framework to provide a declarative, event-driven architecture for metrics exploration.
- Declarative State Management: Uses Scenes for declarative UI state with minimal imperative code
- Event-Driven Communication: Components communicate via typed events rather than direct calls
- Variable-Based State: Scene variables serve as single source of truth for all state
- Automatic Query Generation: Intelligent query building based on metric type detection
- URL-Synchronized State: Deep linking with automatic URL synchronization
DataTrail (Root State Container)
Controls (Time picker, datasource, variables)
TopScene (Dynamic content switcher)
MetricsReducer (Main browsing interface)
ListControls (Search, sorting, layout)
SideBar (Filters, grouping, bookmarks)
Body (SimpleMetricsList | MetricsGroupByList)
Drawer (Function selection overlay)
MetricScene (Individual metric visualization)
MetricGraphScene (Main chart/graph)
ActionTabs (Breakdown, Related, Logs)
- Frontend: React 18 + TypeScript 5.8
- State Management: Grafana Scenes 6.10+
- Build System: Webpack 5 + SWC for compilation
- Testing: Jest (unit) + Playwright (E2E)
- Performance: WASM integration via
@bsull/augurs - Code Quality: ESLint + Prettier + TypeScript strict mode
The plugin runs against a local Grafana instance via Docker:
- Grafana Server: Runs on port 3001 (configurable via
GRAFANA_PORTenv var) - Plugin Development: Uses webpack dev server with hot reload
- Docker Compose: Provides Grafana + Prometheus setup for testing
Create .env file for custom configuration:
# Required for macOS/Windows local development
DOCKER_HOST_IP=host-gateway
# Optional: Custom ports
GRAFANA_PORT=3001
NODE_ENV=development- macOS/Windows Local Development: REQUIRED - Create a
.envfile withDOCKER_HOST_IP=host-gateway - Linux CI Environments: Uses default
172.17.0.1automatically (no configuration needed) - This allows Grafana containers to connect to services running on the host machine (e.g., Prometheus/Loki running in other Docker networks)
VSCode Settings (recommended):
{
"typescript.preferences.importModuleSpecifier": "relative",
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode"
}Scene Objects: Core building blocks that manage state and rendering
import { SceneObjectBase, SceneObjectState } from '@grafana/scenes';
interface MySceneState extends SceneObjectState {
myProperty: string;
}
class MyScene extends SceneObjectBase<MySceneState> {
public static Component = ({ model }: SceneComponentProps<MyScene>) => {
const { myProperty } = model.useState();
return <div>{myProperty}</div>;
};
}Scene Variables: Reactive state containers with automatic dependency management
import { SceneVariableSet, ConstantVariable } from '@grafana/scenes';
const variables = new SceneVariableSet({
variables: [new ConstantVariable({ name: 'datasource', value: 'prometheus' })],
});Event Definition: Typed events for component communication
interface EventFiltersChangedPayload {
type: FilterType;
filters: string[];
}
export class EventFiltersChanged extends BusEventWithPayload<EventFiltersChangedPayload> {
static type = 'filters-changed';
}Event Publishing: Components emit events for state changes
// Publisher
this.publishEvent(
new EventFiltersChanged({
type: 'prefix',
filters: selectedFilters,
}),
true
);
// Subscriber
this._subs.add(
this.subscribeToEvent(EventFiltersChanged, (event) => {
this.handleFiltersChanged(event.payload);
})
);Core Variables:
MetricsVariable: Master metrics list with lifecycle eventsFilteredMetricsVariable: Filtered/sorted subset using enginesAdHocFiltersVariable: Dynamic label-based filtersLabelsVariable: Group-by label selection
Variable Dependencies: Automatic updates when dependencies change
const filteredMetrics = new FilteredMetricsVariable({
name: 'filteredMetrics',
datasource: datasourceVariable,
$variables: new SceneVariableSet({ variables: [metricsVariable] }),
});Purpose: Root state container and main coordinator Responsibilities:
- Manages metric selection and navigation
- Handles URL synchronization and browser history
- Persists recent trails and bookmarks
- Coordinates between MetricsReducer and MetricScene
Key Methods:
selectMetric(metric: string, options?: { skipUrlSync?: boolean })
showMetrics()
updateTimeRange(timeRange: TimeRange)Purpose: Main metrics browsing interface Responsibilities:
- Orchestrates filtering and sorting engines
- Manages centralized event handling
- Controls UI layout and component switching
- Handles variable lifecycle coordination
Architecture Pattern:
class MetricsReducer extends SceneObjectBase<MetricsReducerState> {
private _subs = new Subscription();
private enginesMap = new Map<string, EngineContext>();
public activate() {
this.setupEventSubscriptions();
this.registerEngines();
}
private setupEventSubscriptions() {
this._subs.add(this.subscribeToEvent(EventFiltersChanged, this.handleFiltersChanged));
}
}Purpose: Individual metric visualization and exploration Responsibilities:
- Auto-generates queries based on metric type
- Manages action tabs (Breakdown, Related Metrics, Logs)
- Handles drill-down interactions
- Provides metric-specific visualizations
Filter Engine (MetricsVariableFilterEngine):
class MetricsVariableFilterEngine {
applyFilters(filters: FilterMap): void {
this.currentFilters = { ...this.currentFilters, ...filters };
this.updateVariable();
}
private updateVariable(): void {
const filtered = this.originalMetrics.filter((metric) => this.passesAllFilters(metric));
this.variable.setState({ metrics: filtered });
}
}Sort Engine (MetricsVariableSortEngine):
class MetricsVariableSortEngine {
sort(sortBy: SortBy): void {
const metrics = [...this.variable.state.metrics];
switch (sortBy) {
case SortBy.Alphabetical:
return this.alphabeticalSort(metrics);
case SortBy.Usage:
return this.usageBasedSort(metrics);
case SortBy.WasmSort:
return this.wasmSort(metrics);
}
}
}- Initial Load: DataTrail initializes with URL state or defaults
- Variable Setup: Scene variables created with dependencies
- Engine Registration: Filter/sort engines register with variables
- Event Flow: User interactions trigger events � engines update � UI refreshes
- URL Sync: State changes automatically sync to URL for deep linking
TrailStore (src/TrailStore/TrailStore.ts):
- Recent Trails: localStorage with debounced saves (1 second)
- Bookmarks: User-created persistent references
- URL Serialization: Trail state serialized as URL parameters
- Memory Management: Weak references prevent memory leaks
class TrailStore {
private saveDebounced = debounce(() => {
localStorage.setItem(RECENT_TRAILS_KEY, JSON.stringify(this.recent));
}, 1000);
addToRecent(trail: DataTrail): void {
const urlState = trail.getSceneUrl();
this.recent = this.deduplicateTrails([...this.recent, { urlState }]);
this.saveDebounced();
}
}Automatic Synchronization: Scene state automatically syncs with URL
const dataTrail = new DataTrail({
$timeRange: new SceneTimeRange({ from: 'now-1h', to: 'now' }),
$variables: variableSet,
$urlSync: new SceneObjectUrlSyncConfig(dataTrail, { updateSearchParams: true }),
});EventMetricsVariableActivated; // Variable becomes active
EventMetricsVariableDeactivated; // Variable becomes inactive
EventMetricsVariableLoaded; // Variable data loadedEventFiltersChanged; // Filter selection changes
EventSortByChanged; // Sort option changes
EventQuickSearchChanged; // Search input changes
EventSectionValueChanged; // Sidebar section changesMetricSelectedEvent; // Metric selected
RefreshMetricsEvent; // Metrics refresh requestedCentralized Event Handling (MetricsReducer):
private setupEventSubscriptions(): void {
this._subs.add(
this.subscribeToEvent(EventFiltersChanged, (event) => {
const { type, filters } = event.payload;
for (const [, { filterEngine, sortEngine }] of this.state.enginesMap) {
filterEngine.applyFilters({ [type]: filters });
sortEngine.sort(sortByVariable.state.value);
}
this.forceUpdate();
})
);
}Event Publishing (Component level):
const handleFilterChange = (filters: string[]) => {
sceneRef.current?.publishEvent(
new EventFiltersChanged({ type: 'prefix', filters }),
true // bubble up
);
};Configuration: Uses SWC for fast compilation
// jest.config.js
module.exports = {
transform: {
'^.+\\.(t|j)sx?$': [
'@swc/jest',
{
/* config */
},
],
},
transformIgnorePatterns: [nodeModulesToTransform(esModules)],
collectCoverageFrom: ['./src/**'],
};Testing Patterns:
// Component testing with scene activation
describe('MetricsReducer', () => {
let scene: MetricsReducer;
beforeEach(() => {
scene = new MetricsReducer({});
scene.activate(); // Important: activate scene before testing
});
it('should handle filter events', () => {
scene.publishEvent(
new EventFiltersChanged({
type: 'prefix',
filters: ['test'],
})
);
expect(scene.state.someProperty).toBe(expectedValue);
});
});Mock Strategy:
// Comprehensive mocks for Grafana APIs
jest.mock('@grafana/runtime', () => ({
getDataSourceSrv: () => mockDataSourceService,
config: { theme2: mockTheme },
}));Architecture: Page Object Model with reusable fixtures
// e2e/fixtures/views/MetricsReducerView.ts
export class MetricsReducerView {
constructor(private page: Page) {}
async goto(): Promise<void> {
await this.page.goto('/a/grafana-metricsdrilldown-app/drilldown');
}
async assertCoreUI(): Promise<void> {
await expect(this.page.getByTestId('sidebar')).toBeVisible();
await expect(this.page.getByTestId('metrics-list')).toBeVisible();
}
}Test Categories:
- Core UI Behavior: Navigation, component visibility, interactions
- Filtering & Sorting: Sidebar filters, search functionality, sort options
- Metric Selection: Selection flow, visualization updates
- Visual Regression: Screenshot comparison for UI consistency
Example Test:
// e2e/tests/metrics-reducer-view.spec.ts
test('should display filtered metrics correctly', async ({ metricsReducerView }) => {
await metricsReducerView.goto();
await metricsReducerView.selectFilter('prefixes', 'prometheus_');
await metricsReducerView.assertFilteredResults('prometheus_');
});TypeScript Configuration:
- Strict mode enabled
- Target: ES2022
- Module: CommonJS (for Jest compatibility)
ESLint Rules:
{
"extends": ["@grafana/eslint-config"],
"rules": {
"@typescript-eslint/no-explicit-any": "error",
"prefer-const": "error",
"import/order": ["error", { "alphabetize": { "order": "asc" } }]
}
}-
Branch Naming:
feat/feature-name,fix/bug-description,chore/task-name -
Commit Messages: Follow conventional commits
feat: add native histogram support fix: resolve memory leak in scene transitions chore: update dependencies -
Pull Request Process:
- Ensure all tests pass (
pnpm run test,pnpm run e2e) - Run linting (
pnpm run lint:fix) - Type checking passes (
pnpm run typecheck) - Add appropriate tests for new functionality
- Ensure all tests pass (
Component Structure:
interface MySceneState extends SceneObjectState {
// State interface
}
export class MyScene extends SceneObjectBase<MySceneState> {
// Constructor
public constructor(state: Partial<MySceneState>) {
super({ ...defaultState, ...state });
}
// Lifecycle methods
public activate(): void {
super.activate();
// Setup subscriptions, initialize data
}
public deactivate(): void {
// Cleanup subscriptions
super.deactivate();
}
// Static component
public static Component = ({ model }: SceneComponentProps<MyScene>) => {
const state = model.useState();
return <div>{/* JSX */}</div>;
};
}Event Definition Pattern:
export interface EventPayload {
property: string;
}
export class MyEvent extends BusEventWithPayload<EventPayload> {
static type = 'my-event';
}Unit Tests: Required for all new components and utilities
- Test component behavior and state management
- Mock external dependencies
- Achieve >80% coverage for new code
E2E Tests: Required for new UI features
- Test complete user workflows
- Include screenshot testing for visual changes
- Test across different viewport sizes
src/
ComponentName/ # Feature-based organization
ComponentName.tsx # Main component
ComponentName.test.tsx # Unit tests
subcomponents/ # Related components
utils.ts # Component-specific utilities
utils/ # Shared utilities
types.ts # Shared type definitions
constants.ts # Application constants
Augurs Library: Used for performance-critical operations
// Lazy loading WASM module
const initializeWasm = async () => {
try {
const { OutlierDetector } = await import('@bsull/augurs');
return new OutlierDetector();
} catch (error) {
console.warn('WASM not supported, falling back to JS implementation');
return null;
}
};Scene Lifecycle: Proper activation/deactivation prevents memory leaks
public activate(): void {
super.activate();
this._subs.add(/* subscriptions */);
}
public deactivate(): void {
this._subs.unsubscribe(); // Important: cleanup subscriptions
super.deactivate();
}Debounced Operations: Reduce unnecessary computations
private updateFiltersDebounced = debounce(() => {
this.applyFilters();
}, 300);Code Splitting: Lazy load heavy components
const MetricScene = React.lazy(() => import('./MetricScene'));Bundle Analysis: Regular monitoring
pnpm run analyze # Opens webpack-bundle-analyzerProblem: Changes to scene state don't trigger re-renders
Solution: Use this.setState() instead of direct state mutation
// L Wrong
this.state.myProperty = newValue;
// � Correct
this.setState({ myProperty: newValue });Problem: Events not received by subscribers Solution: Ensure proper subscription lifecycle
public activate(): void {
super.activate();
this._subs.add(
this.subscribeToEvent(MyEvent, this.handleEvent.bind(this))
);
}
public deactivate(): void {
this._subs.unsubscribe(); // Critical for cleanup
super.deactivate();
}Problem: Scene state not syncing with URL Solution: Verify SceneObjectUrlSyncConfig setup
const scene = new MyScene({
$urlSync: new SceneObjectUrlSyncConfig(scene, {
updateSearchParams: true,
}),
});Problem: Variable doesn't update when dependencies change Solution: Check VariableDependencyConfig
const variable = new MyVariable({
$variables: dependencyVariableSet,
variableDependency: new VariableDependencyConfig(variable, {
statePaths: ['path.to.dependency'],
}),
});React DevTools: Install browser extension for React debugging
Scene Inspector: Access via window.__SCENES_DEBUG__ in console
Network Panel: Monitor data source queries and responses
Performance Panel: Profile rendering and state updates
TypeScript Errors: Run pnpm run typecheck for detailed error messages
SWC Compilation: Clear Jest cache if tests fail: pnpm exec jest --clearCache
Webpack Bundle: Use pnpm run analyze to investigate bundle size issues
Jest + ESM: Ensure modules are listed in transformIgnorePatterns
Playwright Failures: Run pnpm run e2e:watch for interactive debugging
Coverage Issues: Check collectCoverageFrom patterns in jest.config.js
- Grafana Plugin Development: https://grafana.com/developers/plugin-tools/
- Grafana Scenes Documentation: https://grafana.com/developers/scenes/
- React 18 Documentation: https://react.dev/
- TypeScript Handbook: https://www.typescriptlang.org/docs/
- Playwright Testing: https://playwright.dev/
This guide provides the foundation for contributing to the Grafana Metrics Drilldown plugin. The architecture emphasizes maintainability, performance, and developer experience through well-defined patterns and comprehensive testing strategies.