Use when adding support for a new event or metric in the analytics exporter at zeebe/exporters/analytics-exporter/ — creating handlers, adding AnalyticsAttributes, registering in the HandlerRegistry, and writing tests. Also use when modifying existing handlers or attributes.
73
90%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Passed
No findings from the security scan
Reference for extending the analytics exporter with a new event handler. The exporter ships process-level OTel telemetry to the Camunda Analytics backend; downstream dashboards and alerts depend on stable attribute key strings and event names across versions. Getting registration or backwards compatibility wrong silently drops data or breaks analytics.
Iron rule — NEVER expose PII. Variable values, usernames, email addresses, user IDs, or any other personally identifiable data must never be emitted. Only process metadata (process IDs, definition keys, instance keys, element IDs, tenant IDs, timestamps) is acceptable. When in doubt, leave it out.
zeebe/exporters/analytics-exporter/src/main/java/io/camunda/exporter/analytics/
AnalyticsExporter.java ← entry point; configure() wires up the HandlerRegistry
AnalyticsHandler.java ← @FunctionalInterface — implement this
HandlerRegistry.java ← routes (ValueType, Intent) → handler
AnalyticsAttributes.java ← all OTel attribute keys and event/metric name constants
OtelSdkManager.java ← logEvent() / incrementMetric() / emitHeartbeat()
handler/ ← one class per event typeDetermine the ValueType, Intent, and RecordValue type from the Zeebe protocol:
zeebe/protocol/src/main/java/io/camunda/zeebe/protocol/record/value/
zeebe/protocol/src/main/java/io/camunda/zeebe/protocol/record/intent/Check HandlerRegistry in AnalyticsExporter.configure() to confirm there is no existing
handler for that (ValueType, Intent) pair — the registry throws IllegalStateException on
duplicate registration.
One handler per
(ValueType, Intent). If the same intent covers multiple element types (likePROCESS_INSTANCE / ELEMENT_ACTIVATED), add filtering logic inside the handler (seeAdHocSubProcessHandlerfor an example).
Open AnalyticsAttributes.java and add any new AttributeKey constants or string constants.
AnalyticsAttributes is organized into domain-specific nested classes (Process, Event,
Tenant, Element, Metric, etc.). Add new constants to the appropriate nested class, or
create a new one if a new domain is needed.
OTel naming rules:
"camunda.job.type""job_created" (go inside the Event nested class)"camunda.job.created" (go inside Metric)Keep attribute count minimal. Every attribute added to a metric becomes a dimension in the time-series backend. Too many attributes — especially high-cardinality ones — cause dimension explosion and drive up storage and query costs. Only add attributes that are genuinely needed. For log events this is less critical, but the same principle applies.
Iron rule — never remove or rename existing constants. Attribute key strings, event names, and metric names are part of the analytics schema. They are baked into downstream dashboards, queries, and alerts. Renaming or removing one silently breaks consumers. Only ever add new constants. If semantics change, add a new constant alongside the old one.
Adding a new domain (e.g. Job):
public static final class Job {
public static final AttributeKey<String> TYPE = AttributeKey.stringKey("camunda.job.type");
public static final AttributeKey<String> WORKER = AttributeKey.stringKey("camunda.job.worker");
private Job() {}
}Adding an event name for the new event (inside the existing Event nested class):
public static final class Event {
// ... existing constants ...
public static final String JOB_CREATED = "job_created";
private Event() {}
}Create handler/MyEventHandler.java in the same package as the other handlers.
Choose the correct category by implementing category() (there is no default — every handler
must make a deliberate choice):
AnalyticsCategory.CONTRACTUAL — commercial/licence metrics (process instances, decision
instances, task users, tenant events, usage metrics)AnalyticsCategory.OPTIONAL — non-commercial product usage metrics (ad-hoc subprocess activations,
feature adoption signals)The category determines whether the handler is active based on the exporter's categories
configuration. If a category is removed from the config array, all handlers in that category are
excluded at startup. Changing categories also changes the exporter digest fingerprint.
package io.camunda.exporter.analytics.handler;
import static io.camunda.exporter.analytics.AnalyticsAttributes.Event.MY_EVENT;
import static io.camunda.exporter.analytics.AnalyticsAttributes.Process.BPMN_PROCESS_ID;
import io.camunda.exporter.analytics.AnalyticsAttributes;
import io.camunda.exporter.analytics.AnalyticsCategory;
import io.camunda.exporter.analytics.AnalyticsHandler;
import io.camunda.exporter.analytics.OtelSdkManager;
import io.camunda.zeebe.protocol.record.Record;
import io.camunda.zeebe.protocol.record.value.MyRecordValue;
import java.util.Objects;
import java.util.concurrent.TimeUnit;
public final class MyEventHandler implements AnalyticsHandler<MyRecordValue> {
private final OtelSdkManager otelSdkManager;
public MyEventHandler(final OtelSdkManager otelSdkManager) {
this.otelSdkManager = Objects.requireNonNull(otelSdkManager);
}
@Override
public AnalyticsCategory category() {
// CONTRACTUAL — commercial/licence metrics (process instances, decision instances, task users)
// OPTIONAL — non-commercial product usage metrics (e.g. ad-hoc subprocess activations)
return AnalyticsCategory.CONTRACTUAL;
}
@Override
public void handle(final Record<MyRecordValue> record) {
final var value = record.getValue();
// Optional: filter on a sub-condition and return early if not applicable.
otelSdkManager.logEvent(
MY_EVENT,
record.getPosition(),
log ->
log.setAttribute(BPMN_PROCESS_ID, value.getBpmnProcessId())
// Tenant.ID and Element.ID share the unqualified name ID — use qualified form
.setAttribute(AnalyticsAttributes.Tenant.ID, value.getTenantId())
.setTimestamp(record.getTimestamp(), TimeUnit.MILLISECONDS));
}
}Import style: use explicit static imports from the nested class (e.g.
AnalyticsAttributes.Process.BPMN_PROCESS_ID). When the unqualified name would be ambiguous
(e.g. both Tenant.ID and Element.ID are named ID), use the qualified form
AnalyticsAttributes.Tenant.ID directly rather than a static import.
Use otelSdkManager.logEvent() for discrete events and otelSdkManager.incrementMetric() for
counters/gauges. See ProcessInstanceCreationHandler for an example that uses both.
Open AnalyticsExporter.configure() and add a .register(...) call to the HandlerRegistry
chain:
handlers =
new HandlerRegistry()
...
.register(
ValueType.MY_VALUE_TYPE,
MyIntent.MY_INTENT,
new MyEventHandler(otelSdkManager))
.apply(context);The apply(context) call installs an AnalyticsRecordFilter. The filter is an
over-approximation: it accepts records whose ValueType is in the registered set and whose
Intent is in the registered set, but those two sets are evaluated independently — a record can
pass the filter even if its exact (ValueType, Intent) pair has no handler. Exact routing and
no-ops happen in HandlerRegistry.handle(). No other change is needed for filtering.
Create handler/MyEventHandlerTest.java:
class MyEventHandlerTest {
// io.camunda.zeebe.test.broker.protocol.ProtocolFactory
private static final ProtocolFactory FACTORY = new ProtocolFactory();
private InMemoryLogRecordExporter memoryExporter;
private MyEventHandler handler;
@BeforeEach
void setUp() {
memoryExporter = InMemoryLogRecordExporter.create();
handler = new MyEventHandler(TestOtelSdkManager.inMemory(memoryExporter));
// For metric assertions: TestOtelSdkManager.inMemoryWithMetrics(logExporter, metricReader)
}
@Test
void shouldEmitEventWithCorrectAttributes() {
// given
final var value = ImmutableMyRecordValue.builder()
.withBpmnProcessId("my-process")
.withTenantId("tenant-a")
.build();
final var record = FACTORY.generateRecord(
ValueType.MY_VALUE_TYPE,
r -> r.withRecordType(RecordType.EVENT)
.withIntent(MyIntent.MY_INTENT)
.withValue(value));
// when
handler.handle(typed(record));
// then
assertThat(memoryExporter.getFinishedLogRecordItems())
.singleElement()
.satisfies(log ->
assertThat(log.getAttributes().asMap())
.containsEntry(AnalyticsAttributes.Event.NAME, AnalyticsAttributes.Event.MY_EVENT)
.containsEntry(AnalyticsAttributes.Process.BPMN_PROCESS_ID, "my-process")
.containsEntry(AnalyticsAttributes.Tenant.ID, "tenant-a"));
}
// If the handler silently skips some records, test that path too:
@Test
void shouldSkipUnmatchedRecords() {
// given — build a record that should be filtered out
// when
handler.handle(typed(unrelatedRecord));
// then
assertThat(memoryExporter.getFinishedLogRecordItems()).isEmpty();
}
@SuppressWarnings("unchecked")
private static <T extends RecordValue> Record<T> typed(final Record<?> record) {
return (Record<T>) record;
}
}Required test cases per handler:
incrementMetric() accumulates across multiple callsAdd a test to AnalyticsExporterTest that feeds a record of the new type through the full
exporter (exporter.export(record)) and asserts the expected event name appears. The test
setup (exporter, memoryExporter, controller) is already provided by @BeforeEach:
@Test
void shouldEmitMyEventWhenRecordExported() {
// given
final var record =
FACTORY.generateRecord(
ValueType.MY_VALUE_TYPE,
r -> r.withRecordType(RecordType.EVENT).withIntent(MyIntent.MY_INTENT));
// when
exporter.export(record);
// then
assertThat(memoryExporter.getFinishedLogRecordItems())
.singleElement()
.satisfies(
log ->
assertThat(log.getAttributes().get(AnalyticsAttributes.Event.NAME))
.isEqualTo(AnalyticsAttributes.Event.MY_EVENT));
}Note on
Immutable*RecordValuebuilders. When a test needs to set specific field values on the record (e.g. element type, process ID), use the generatedImmutable*builder fromzeebe-protocol-immutables, e.g.ImmutableProcessInstanceRecordValue.builder(). See the existing handler tests inhandler/*HandlerTest.javafor exact usage.
Open zeebe/exporters/analytics-exporter/README.md and update it in lockstep with the code
changes above — the README is the source of truth downstream consumers read to understand
what the exporter emits, and it drifts silently if this step is skipped:
AnalyticsAttributes.java. Every attribute key string
written in the README must match the actual constant value in code, not just look
plausible. Cross-check each one you add (and, ideally, any existing ones you touch)
against the real AttributeKey/string constant — README prose can drift from the code
over time, so don't introduce or perpetuate that class of mismatch.# Format before committing (mandatory when touching Java/markdown/pom.xml)
./mvnw license:format spotless:apply -T1C
# Run the analytics exporter tests
./mvnw verify -pl zeebe/exporters/analytics-exporter -DskipTests=false -Dquickly -T1CAll tests must pass before committing.
Before opening the PR, go through this checklist:
AnalyticsAttributes are
unchanged; only new constants were added..register(ValueType, Intent, handler) call is present in
AnalyticsExporter.configure()../mvnw verify -pl zeebe/exporters/analytics-exporter -DskipTests=false -Dquickly -T1C is green.zeebe/exporters/analytics-exporter/README.md updated — the new event type is listed in the Event types table and
its specific attributes are documented, with attribute names matching the
AnalyticsAttributes constants.| File | Purpose |
|---|---|
AnalyticsAttributes.java | Add new AttributeKey constants and event/metric name strings here |
handler/ | One class per event type; implement AnalyticsHandler<T> |
AnalyticsExporter.java:configure() | Register new handlers in the HandlerRegistry chain |
TestOtelSdkManager.java | Test factory — inMemory() for log-only, inMemoryWithMetrics() for both |
handler/*HandlerTest.java | Pattern to follow for handler unit tests |
AnalyticsExporterTest.java | Integration-level wiring test to extend |
zeebe/exporters/analytics-exporter/README.md | Update when adding/changing event types or attributes |
dfaeeff
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.