ph-telemetry
June 16, 2026 · View on GitHub
If this project saved you some time or made your day a little easier, a star would mean a lot — it helps others find it too.
Java 17+ vendor-neutral telemetry abstraction (tracing + metrics) with a pluggable OpenTelemetry binding. Lets libraries emit spans and instruments without pulling the OpenTelemetry API into their dependency graph, and lets applications swap in a real backend (OpenTelemetry out of the box; any ServiceLoader-registered SPI implementation — Jaeger, Zipkin, a custom recorder, etc. — works the same way) or a no-op fallback.
Licensed under the Apache 2.0 license.
Modules
ph-telemetry— the abstraction itself. Static facadesTelemetry(tracing) andTelemetryMetrics(counters / up-down counters / histograms / observable gauges), backed by SPIs (ITelemetryTracerSPI,ITelemetryMeterSPI). If no SPI is registered, both facades transparently degrade to cheap no-ops, so libraries can emit telemetry unconditionally without forcing the cost or the dependency on downstream consumers.ph-telemetry-otel— the OpenTelemetry binding. ProvidesOtelTelemetryTracerSPIandOtelTelemetryMeterSPIas subclassable base classes that resolve the SDK viaGlobalOpenTelemetry. Project applications subclass them with a no-arg constructor supplying an instrumentation scope name + version, register the subclass viaMETA-INF/services, and letServiceLoaderwire it all up at runtime.
Maven usage
Add the following to your pom.xml, where x.y.z is the latest released version:
<dependency>
<groupId>com.helger.telemetry</groupId>
<artifactId>ph-telemetry</artifactId>
<version>x.y.z</version>
</dependency>
<dependency>
<groupId>com.helger.telemetry</groupId>
<artifactId>ph-telemetry-otel</artifactId>
<version>x.y.z</version>
</dependency>
Or import the BOM and skip per-module versions:
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.helger.telemetry</groupId>
<artifactId>ph-telemetry-parent-pom</artifactId>
<version>x.y.z</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
Note: prior to v1.0.0 the abstraction shipped from ph-commons as com.helger.commons:ph-telemetry. That module is now @Deprecated(forRemoval = true); switch the dependency over.
Usage
Emitting a span
import com.helger.telemetry.ETelemetrySpanKind;
import com.helger.telemetry.Telemetry;
Telemetry.withSpanVoid ("outbound.send", ETelemetrySpanKind.PRODUCER, aSpan -> {
aSpan.setAttribute ("transaction.id", sTxID);
... business work ...
aSpan.setStatusOk ();
});
Exceptions thrown inside the body are automatically recorded on the span and the status is set to ERROR. If no tracer SPI is registered, the body still runs and aSpan is a no-op.
ETelemetrySpanKind mirrors OpenTelemetry's SpanKind: INTERNAL, CLIENT, SERVER, PRODUCER, CONSUMER. Use Telemetry.withSpan (name, kind, body) when the body needs to return a value; withSpanVoid (...) for the void-returning case. Both start the span, record exceptions, set OK/ERROR status, and close the span in a finally block. Telemetry.startSpan (...) is also available for callers that want to manage the lifecycle manually — ITelemetrySpan exposes typed attribute setters (setAttribute (String, String|long|double|boolean)), recordException (Throwable), and setStatusOk () / setStatusError (String).
Bodies that throw checked exceptions
withSpan (...) and withSpanVoid (...) take a Function / Consumer and therefore cannot accept a body that declares a checked exception. Use the *Throwing variants when the body needs to throw — they take IThrowingSpanFunction <T, E> / IThrowingSpanConsumer <E>, propagate E from the call, and still record the exception on the span before re-throwing:
// returns a value, may throw IOException
final byte[] aPayload = Telemetry.<byte[], IOException> withSpanThrowing (
"payload.read", ETelemetrySpanKind.INTERNAL, aSpan -> {
final byte[] aBytes = readRequestBody ();
aSpan.setAttribute ("payload.size_bytes", aBytes.length);
return aBytes;
});
// void, may throw IOException
Telemetry.<IOException> withSpanVoidThrowing (
"outbound.send", ETelemetrySpanKind.PRODUCER, aSpan -> {
aSpan.setAttribute ("transaction.id", sTxID);
sendOverHttp (...); // throws IOException
});
The throwing variants catch Throwable (not just RuntimeException), so they also handle Error correctly: the exception is recorded on the span and the original is always re-thrown — a defective backend that itself throws from recordException cannot mask the user's exception.
Recording metrics
import com.helger.telemetry.ITelemetryCounter;
import com.helger.telemetry.TelemetryAttributes;
import com.helger.telemetry.TelemetryMetrics;
public final class MyMetrics
{
public static final ITelemetryCounter REQUESTS_RECEIVED = TelemetryMetrics.counter (
"myapp.requests.received",
"Inbound requests accepted by the service",
"{request}");
private MyMetrics () {}
}
// at the call site:
MyMetrics.REQUESTS_RECEIVED.add (1,
TelemetryAttributes.builder ().put ("route", sRoute).build ());
Wiring OpenTelemetry
In your application module, subclass each binding with a no-arg constructor that supplies your instrumentation scope:
public final class MyAppTracerSPI extends OtelTelemetryTracerSPI
{
public MyAppTracerSPI ()
{
super ("com.example.myapp", MyAppVersion.BUILD_VERSION);
}
}
public final class MyAppMeterSPI extends OtelTelemetryMeterSPI
{
public MyAppMeterSPI ()
{
super ("com.example.myapp", MyAppVersion.BUILD_VERSION);
}
}
Register them via two META-INF/services files:
META-INF/services/com.helger.telemetry.ITelemetryTracerSPI
-> com.example.myapp.MyAppTracerSPI
META-INF/services/com.helger.telemetry.ITelemetryMeterSPI
-> com.example.myapp.MyAppMeterSPI
Initialise the OpenTelemetry SDK once at application startup (e.g. via AutoConfiguredOpenTelemetrySdk.builder().setResultAsGlobal().build()). The SPI bindings resolve the SDK from GlobalOpenTelemetry on first use; until the SDK is installed, the OTel no-op returned by GlobalOpenTelemetry.get() keeps the whole pipeline cheap.
Tests
Tests can install a custom recording SPI without needing an SDK:
@After public void tearDown () { Telemetry.install (null); }
@Test public void example ()
{
Telemetry.install ((sName, eKind) -> myRecordingSpan);
... exercise code that calls Telemetry.startSpan ...
}
TelemetryMetrics.install (...) works the same way for the metrics side.
News and noteworthy
v1.0.1 - 2026-06-16
- New
Telemetry.withSpanThrowing (...)andTelemetry.withSpanVoidThrowing (...)variants that accept a body declaring a checked exception (IThrowingSpanFunction <T, E>/IThrowingSpanConsumer <E>). The throwable is recorded on the span and re-thrown without wrapping — callers no longer need to smuggle a checked exception through aRuntimeException. Both variants catchThrowableand defensively guard therecordExceptioncall so a defective backend cannot mask the user's exception.
v1.0.0 - 2026-06-12
- Initial release as a standalone repository.
The abstraction (
Telemetry,TelemetryMetrics,ITelemetryTracerSPI,ITelemetryMeterSPI,TelemetryAttributes, instrument interfaces, no-op fallbacks) is unchanged from its previous home inph-commons:ph-telemetryv12.3.0 — only the Maven coordinates moved fromcom.helger.commons:ph-telemetrytocom.helger.telemetry:ph-telemetry. - New module
ph-telemetry-otelextracted from per-project OpenTelemetry bindings. ProvidesOtelTelemetryTracerSPIandOtelTelemetryMeterSPIas subclassable base classes that wrap the OpenTelemetry API; project subclasses supply only the instrumentation scope name and version. ph-telemetry-oteldepends onopentelemetry-apionly — applications that also need the SDK (autoconfigure, OTLP exporter, etc.) pull those dependencies themselves at the deployment boundary.
My personal Coding Styleguide | It is appreciated if you star the GitHub project if you like it.