New to BTrace? Start with the BTrace Tutorial. The tutorial's Lesson 6 covers using existing extensions and includes a Writing Your Own Extension quick-start that links back here for full details.
BTrace extensions provide reusable services that can be injected into BTrace scripts. This guide covers the recommended, plugin-based workflow using a single Gradle module with a single authored source tree under src/main. The plugin still separates runtime artifacts, generates metadata, shades implementation dependencies, and prepares distributables.
For API authoring rules that the build verifies, see ExtensionInterfaceRules.md.
Extensions are isolated while exposing only their API to scripts:
Bootstrap ClassLoader
├── JRE classes
├── btrace.jar bootstrap section (BTrace core + extension APIs)
└── Extension ClassLoaders (isolated)
├── Extension 1 (e.g., btrace-metrics)
├── Extension 2 (e.g., btrace-statsd)
└── Extension N (your extension)
Script ClassLoader (parent = null)
├── Script classes
└── Accesses extensions via invokedynamic bridge
Use a single Gradle module with one authored source tree:
your-extension/
├── build.gradle
└── src/
├── main/java/... (API + impl authored together)
└── main/resources/...
- API types are resolved by scripts (end up on bootstrap).
- Impl is isolated behind an extension classloader with shaded deps.
- The plugin produces an API JAR, a shadowed Impl JAR, and a distributable ZIP.
btraceExtension {
services = [ "org.example.myext.api.MyService" ]
additionalExports = [ "org.example.myext.api.MyValueType" ] // optional
}- Java sources live under
src/main/java - resources live under
src/main/resources - the plugin computes the API closure from declared services and any
additionalExports - output artifacts remain unchanged
Package-level API/impl separation is still strongly recommended even though the physical source root is shared.
Apply the BTrace Gradle Extension Plugin and configure your extension via btraceExtension:
plugins {
id("io.btrace.extension") version "<btraceVersion>"
}
repositories { mavenCentral() }
java {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
dependencies {
// Keep API free of external library types
// Put all runtime libs under Impl (the plugin will shade them)
}
btraceExtension {
id = "org.example.myext" // required: globally unique extension ID
name = "My Extension" // optional
description = "Does useful things" // optional
// Service interfaces that can be injected into scripts
// Auto-detected from @ServiceDescriptor, or declare explicitly:
services = [ "org.example.myext.api.MyService" ]
// Shade Impl dependencies to avoid conflicts
shadedPackages = [
"com.example.dep" : "org.example.myext.shaded.dep"
]
// Permissions
scanPermissions = true // default: infer from Impl bytecode + classpath
requiredPermissions = [ ] // optional additions/overrides
// Optional: other extension IDs you depend on
requiresExtensions = [ ]
}Optional companion to the DSL — @ExtensionDescriptor:
- Declared on your API package’s
package-info.java. Source:btrace-core/src/main/java/io/btrace/core/extensions/ExtensionDescriptor.java. - Fields:
name,version,description,minBTraceVersion,dependencies,permissions. permissionsis the field that does work. The plugin scans it and fails the build when the annotation requires a permission the manifest does not grant, so it is a useful assertion that the code's needs and the shipped manifest agree.- The other fields are not propagated. The
btraceExtensionblock is the single source of the manifest, and the manifest is what the runtime loads and whatbtrace ext inspectreports. Aversionstated only in the annotation describes nothing the build or the runtime will use — leave it unset rather than let it drift from the real artifact version. - The annotation is entirely optional; most of the extensions shipped with BTrace do not declare one.
Outputs produced by the plugin:
- API JAR:
build/libs/<name>-<version>-api.jar(manifest + properties with extension metadata) - Impl JAR:
build/libs/<name>-<version>-impl.jar(shadowed/minimized, isolated at runtime) - Distribution ZIP:
build/distributions/<name>-<version>-extension.zip(bundles API + Impl)
Advanced (optional) knobs in btraceExtension:
additionalExports/excludedExports: optional overrides for the computed exported API set.autoApplyShadow(default true): auto-apply Shadow plugin if not applied.nullableAnnotations/nonnullAnnotations: additional nullability annotations (FQCN) for API linting.nullabilitySeverity(off|warn|error): nullability lint severity.shimabilitySeverity(warn|error): shim-compatibility lint severity.apiCtorSeverity(off|warn|error): flag public constructors in API classes.generateShimsReachableOnly(default true): generate shims only for interfaces reachable from declared services.
Define injectable service interfaces. Use the descriptors to help discovery and permission modeling.
package org.example.myext.api;
import io.btrace.core.extensions.Permission;
import io.btrace.core.extensions.ServiceDescriptor;
@ServiceDescriptor(permissions = { Permission.THREADS })
public interface MyService {
MyMetric metric(String name);
}Keep API signatures to JDK and your own API types; avoid external library types.
Provide concrete implementations and extend Extension to access the runtime context when needed.
package org.example.myext.impl;
import io.btrace.core.extensions.Extension;
import org.example.myext.api.MyService;
public final class MyServiceImpl extends Extension implements MyService {
public MyServiceImpl() {}
// implement API methods...
}The plugin shades external libraries present in Impl according to shadedPackages.
package btrace;
import io.btrace.core.annotations.BTrace;
import io.btrace.core.annotations.Injected;
import io.btrace.core.annotations.OnMethod;
import org.example.myext.api.MyService;
@BTrace
public class MyProbe {
@Injected
private static MyService svc;
@OnMethod(clazz = "com.example.App", method = "doWork")
public static void onDoWork() {
svc.metric("work");
}
}When an extension's impl needs to interact with application-specific classes (Spark event objects, Hadoop types, custom framework classes), use the @ExternalType annotation to generate reflective adapters at build time. The extension Gradle plugin auto-registers the annotation processor. This is the normative reference for the supported adapter boundary.
Declare an interface in src/main/java annotated with @ExternalType("fully.qualified.AppType"):
package org.example.ext.api;
import io.btrace.core.extensions.ExternalType;
@ExternalType("org.apache.spark.scheduler.SparkListenerJobStart")
public interface JobStartEvent {
int jobId();
long time();
}The processor generates JobStartEvent$Ext in the same package with a typed public static dispatcher per method. A virtual dispatcher takes Object self first and resolves the configured owner through self's defining loader. A static dispatcher has both the legacy target-arguments-only form, which resolves through the current thread context class loader (TCCL) and falls back to the system loader only when the TCCL is null, and a leading-ClassLoader form that resolves through the supplied loader exactly. Successful resolutions are cached per application loader with weak-identity loader keys; failed class or member lookups are not cached, so a later call can retry. The two static forms share that cache.
Use the generated class directly in the impl:
try {
int id = JobStartEvent$Ext.jobId(event);
long ts = JobStartEvent$Ext.time(event);
// emit metrics / logs...
} catch (ExternalTypeResolutionException unavailable) {
// choose the extension's logging/degradation policy for an unavailable optional API
}For a public target method whose direct signature contains another application type, link it to a second external contract while keeping the generated API opaque:
@ExternalType("vendor.Child")
interface ChildApi {
String label();
}
@ExternalType("vendor.Parent")
interface ParentApi {
@ExternalType.Type(ChildApi.class)
Object child();
}
Object child = ParentApi$Ext.child(parent);
String label = ChildApi$Ext.label(child);The contracts are processor metadata only: child remains an Object and does not implement
ChildApi. The adapter resolves vendor.Child through the already-resolved parent's defining
loader to form the exact method type. A same-name object from another loader fails normally rather
than being coerced; null target values are passed through unchanged.
This explicit Object boundary is intentional, even though it is a little more verbose. Declaring
ChildApi as the return type would falsely promise that the target's vendor.Child object
implements that extension-side interface; a Java cast would then fail. Hiding the conversion would
require rewriting every extension call site or introducing wrappers/proxies, which would add
class-loader, identity, and lifecycle problems. The marker supplies the exact lookup type while
keeping the runtime value honest and directly usable by another generated adapter.
Use an overload group only when the target class exposes two or more public methods with the same
name. The local contract methods may need distinct Java names—especially when two target-library
types are both represented as Object—so give every member the same @ExternalType.Overload
value. That value is the target method name, not a new adapter name.
For example, assume the application library has these two methods:
// Application code; it is not on the extension compile class path.
public final class Parent {
public String describe(String value) { ... }
public String describe(Child value) { ... }
}Declare the generated-adapter contract as follows:
@ExternalType("vendor.Child")
interface ChildApi {
String label();
}
@ExternalType("vendor.Parent")
interface ParentApi {
@ExternalType.Overload("describe")
String describeText(String value);
@ExternalType.Overload("describe")
String describeChild(@ExternalType.Type(ChildApi.class) Object value);
}The local names make the generated Java API unambiguous. At runtime, each dispatcher performs one
exact MethodHandles lookup; it does not inspect the value and choose a compatible candidate.
| Contract declaration | Generated call | Exact target member |
|---|---|---|
describeText(String) |
ParentApi$Ext.describeText(parent, text) |
Parent.describe(String) |
describeChild(@ExternalType.Type(ChildApi.class) Object) |
ParentApi$Ext.describeChild(parent, child) |
Parent.describe(vendor.Child) |
@ExternalType.Type(ChildApi.class) changes the lookup signature from the extension-side
Object to vendor.Child; it does not cast or wrap child. As a result, a child object from a
different application loader is not silently accepted as the other overload.
An overload group has three required properties:
- It contains at least two abstract adapter methods.
- Every method that selects that target name has
@ExternalType.Overload("describe"); do not mix selected and unselected methods for the same target name. - Each member has a distinct exact target signature (name, static/virtual kind, return type, and
parameter types). The normal declared types—and
@ExternalType.Typewhere needed—provide that signature.
Do not use @ExternalType.Overload for a one-method rename. A uniquely named contract method
already binds to the target method with the same name. It cannot enable private members,
generic/array target types, coercion, or fallback lookup; use the manual path for those cases.
Five narrow operation markers cover the common public application-state cases. They are all source-only processor input: generated adapters never inspect annotations at runtime.
@ExternalType("vendor.Child")
interface ChildApi { String label(); }
@ExternalType("vendor.Widget")
interface WidgetApi {
@ExternalType.Getter("name")
String name(); // WidgetApi$Ext.name(Object self)
@ExternalType.Setter("name")
void setName(String value); // WidgetApi$Ext.setName(Object self, String)
@ExternalType.Static
@ExternalType.Getter("DEFAULT_CHILD")
@ExternalType.Type(ChildApi.class)
Object defaultChild(); // defaultChild(), defaultChild(ClassLoader)
@ExternalType.Constructor
Object create(String name); // create(String), create(ClassLoader, String)
@ExternalType.InstanceOf
boolean isWidget(Object value);
@ExternalType.Cast
Object castWidget(Object value);
}Getters have a non-void return and no target arguments; setters return void and take exactly
one target argument. Both use one exact public field type, so a getter/setter pair for the same
field must agree on static state and type. Use @Type(ChildApi.class) Object only for a direct
target-library field return or setter parameter. @Static is legal only for fields (and ordinary
methods), producing the same legacy-TCCL and explicit non-null-loader forms as static methods.
@Constructor returns direct Object; its declared parameters are the exact public constructor
signature and may have direct @Type markers. It has no receiver and therefore also exposes the
legacy and explicit-loader forms. The explicit ClassLoader is adapter control data, never a
constructor argument or part of the constructor MethodType.
@InstanceOf is exactly boolean operation(Object) and @Cast exactly Object operation(Object).
For a non-null candidate, both resolve the owner through the candidate value's defining loader,
then follow Class.isInstance/Class.cast: isInstance(null) is false, cast(null) is null,
and a wrong non-null cast throws normal ClassCastException. They do not use the TCCL.
All field/constructor resolution uses public exact lookup and is retryable after an unavailable
class or member appears. Resolution does not initialize the target; ordinary static-field access
and constructor invocation may initialize it under normal JVM rules. Missing/inaccessible public
members and the predicate/cast public-access probe throw ExternalTypeResolutionException with
the original cause. Target initializer and constructor failures remain transparent.
- Target: interfaces only (
ElementType.TYPE). The processor emits a compile error for classes. - Annotation value: non-empty fully-qualified class name. Empty string is a compile error.
- Method types: declared erased parameter and return types must exactly match the target member's JVM signature. For a direct target-library position, use
@ExternalType.Type(ChildApi.class) Object; it keeps the adapter boundary opaque while resolving the child's target class from the resolved owner's defining loader. The returned value does not implementChildApi, but can flow intoChildApi$Extdirectly. Markers are valid only on directObjectreturns/parameters, not generic elements or arrays. - Access: dispatch uses
MethodHandles.publicLookup(). Only public members of public, accessible types are supported; do not add module-opening flags implicitly. - Static methods: add
@ExternalType.Staticon the interface method.version()preserves legacy TCCL-based class loading with the documented null-TCCL system-loader fallback.version(ClassLoader applicationLoader)resolves with that non-null loader exactly; it rejects null immediately withNullPointerException("applicationLoader"). The control argument is not a target argument. Neither form changes the target's observed TCCL. - Overload groups: follow the overload-group pattern above. The selector changes only the target name; declared exact types, including
Typemarkers, select the overload. It is not a single-method alias or runtime coercion/search facility. - Fields and constructors:
Getter,Setter, andConstructoruse exact public lookup types; fields require a legal raw JVM field name and constructors have no overload selector.Staticis legal only for ordinary methods and fields. - Predicates and casts:
InstanceOfandCastoperate on the owner named by the contract, using the non-null candidate's defining loader. They do not initialize the owner during resolution or bypass public/exported access. - Default methods and static interface methods: skipped (they already have bodies).
- Failures: class lookup, missing member, and inaccessible-member failures throw
ExternalTypeResolutionExceptionwith the original cause. Exceptions thrown by the target method propagate unchanged.
Use generated adapters only for the supported row below. The manual path is the normal solution for version-variant or target-only APIs; it is not an error condition.
| Capability | Phase 1 support | Author action |
|---|---|---|
| Public, uniquely named static or virtual methods with exact erased signatures | Supported | Use @ExternalType; use Object only where the target member itself uses Object. |
A direct target-library Object parameter or return type |
Supported | Mark it with @ExternalType.Type(OtherContract.class) Object; chain opaque results into another generated adapter. |
| Target types in generic elements or arrays | Unsupported | Use ClassLoadingUtil and MethodHandleCache directly. |
| Explicit target overload groups | Supported | Mark every group member with @ExternalType.Overload("targetName"); local names may differ. |
Public exact field get/set, public construction, isInstance, and cast |
Supported | Use the five operation markers above. Keep target-library values opaque with direct @Type(... ) Object positions. |
| Generic/array target types, bulk/reflection-style operations, fluent setters, constructor selectors, runtime overload choice/coercion | Unsupported | Use ClassLoadingUtil plus direct MethodHandles.publicLookup() or Class operations. |
| Non-public or non-exported named-module members | Unsupported | Use a public supported API or explicitly configure the target JVM outside BTrace. |
For a generated static call under an author-controlled application loader, select it explicitly. Normalize a bootstrap-owned context object's null defining loader to the system loader, whose normal delegation reaches bootstrap:
ClassLoader appLoader = ClassLoadingUtil.definingLoader(context);
if (appLoader == null) appLoader = ClassLoader.getSystemClassLoader();
String version = VersionApi$Ext.version(appLoader);Keep ClassLoadingUtil.withTCCL(...) for manual APIs that actually require ambient TCCL policy; it is not needed to call a generated static adapter. Regenerating an adapter adds this overload. A wildcard static import can therefore become ambiguous if another wildcard import provides the same new arity; use a qualified adapter call or an explicit single-member static import.
For version-variant APIs, resolve the exact public signature manually:
Class<?> owner = ClassLoadingUtil.load("com.example.OptionalApi", appLoader);
MethodHandle call = handles.findStatic(owner, "version", String.class, int.class);
String version = (String) call.invoke(3);MethodHandleCache caches successful public static/virtual method lookups only; a caught lookup failure remains retryable. Its current keys retain owner classes and can therefore retain application loaders strongly. It has no getter, setter, or constructor helper. See the provided-style manual linking guide for the complete pattern.
The plugin writes extension metadata into the API JAR manifest and a dedicated properties file; manual manifest editing is not needed. Key attributes include:
BTrace-Extension-Id,BTrace-Extension-Name,BTrace-Extension-DescriptionBTrace-Extension-Services(service interfaces)BTrace-Extension-Permissions(merged from scan + explicitrequiredPermissions)BTrace-Extension-Requires(dependent extension IDs)BTrace-Extension-Impl(Impl artifact coordinates/path)BTrace-Shaded-Packages(diagnostic relocations)
Permission configuration:
btraceExtension {
// Disable inference and declare explicitly (optional)
// scanPermissions = false
requiredPermissions = [ "NETWORK", "THREADS" ]
}At runtime, the agent consults this metadata to validate and enforce permissions.
If you want users to discover your extension through the public catalog, publish the extension artifacts to Maven Central first and then add the extension to the BTrace registry.
The extension registry is planned to live in a dedicated GitHub repository with GitHub Pages hosting; until it is published, treat the entry shape shown below as the working draft.
Registry entries store a single recommended base coordinate:
{
"id": "my-extension",
"name": "My Extension",
"description": "What it does",
"owner": "example-org",
"source_repo": "https://github.com/example-org/my-extension",
"maven": {
"groupId": "org.example",
"artifactId": "my-extension",
"version": "1.2.3"
}
}Consumers are expected to resolve the standard BTrace extension artifacts from that base coordinate.
- Keep the API free of external library types; prefer JDK and your API classes.
- Put all runtime libraries in Impl; use
shadedPackagesto relocate and avoid conflicts. - Do not include BTrace modules in your Impl artifact; only external libs are shaded.
Build artifacts:
- API JAR:
build/libs/<name>-<version>-api.jar - Impl JAR:
build/libs/<name>-<version>-impl.jar - ZIP:
build/distributions/<name>-<version>-extension.zip
Install by copying the ZIP contents (API + Impl) into an extensions directory:
# System-wide
unzip your-extension-<version>-extension.zip -d "$BTRACE_HOME/extensions/"
# User-specific
mkdir -p "$HOME/.btrace/extensions"
unzip your-extension-<version>-extension.zip -d "$HOME/.btrace/extensions/"Discovery locations:
$BTRACE_HOME/extensions/*.jar~/.btrace/extensions/*.jar
Configuration: $BTRACE_HOME/conf/extensions.conf
autoload = true
repositories = [ "${btrace.home}/extensions", "${user.home}/.btrace/extensions" ]For environments where installing extensions separately is impractical (Spark, Hadoop, Kubernetes), extensions can be embedded directly in a fat agent JAR.
BTrace's built-in extensions are extension packages in a BTrace distribution's extensions/
directory (or from the matching packageExtension task in a source checkout). Use file(...) to
embed one, or project(...) for an in-tree/custom extension. maven(...) is reserved for a
separately published third-party extension; BTrace does not publish its bundled extensions as Maven
artifacts.
Use the BTrace Fat Agent Plugin to create a self-contained agent JAR:
plugins {
id 'io.btrace.fat-agent' version '<btraceVersion>'
}
btraceFatAgent {
baseName = 'my-btrace-agent'
embedExtensions {
// Your extension project (if in same multi-project build)
project(':my-extension')
// BTrace-built packages from the distribution's extensions/ directory
file('/path/to/btrace-metrics-3.0.0-extension.zip')
// Local extension ZIPs
file('libs/other-extension.zip')
}
}Build the fat agent:
./gradlew fatAgentJarThe plugin stages your extension:
- API classes → copied as
.classfiles (loaded via bootstrap) - Impl classes → renamed to
.classdata(loaded at runtime byClassDataLoader) - Metadata → written to
META-INF/btrace-extensions/{id}/extension.properties
At agent startup, embedded extensions are automatically discovered from the JAR manifest attribute BTrace-Embedded-Extensions.
# Start application with all embedded extensions
java -javaagent:my-btrace-agent.jar MyApp
# Extensions load automatically - no BTRACE_HOME needed- Simplified Distribution: Ship a single JAR with your extension pre-loaded
- No Installation Required: Users don't need to install extensions separately
- Version Locking: Ensure compatible extension versions are bundled together
- Cloud-Native: Perfect for containers and distributed systems
The unpublished Maven fat-agent module was removed for 3.0.0 because it targets the pre-3.0
extension publication and classdata layout. Use the Gradle fat-agent plugin for embedded
extensions.
Extensions can ship pre-compiled BTrace probe classes inside the fat agent JAR and optionally declare a configurator that tells the agent which probes to activate automatically, without any operator input.
There are two activation modes:
| Mode | How triggered | When to use |
|---|---|---|
| Explicit | probes=ProbeName agent argument |
Operator knows exactly which probe to run |
| Automatic | Configurator detects the environment | Extension selects the right probe based on the running framework/role |
Declare the compiled probe directory and exact binary names in the fat-agent build:
btraceFatAgent {
bundledProbes {
from layout.buildDirectory.dir('compiled-probes').get().asFile
include 'org.example.spark.SparkJobTracer'
include 'org.example.spark.SparkStageTracer'
}
}The corresponding .class files are stored in the fat agent JAR under:
META-INF/btrace-probes/{binary-name-as-package-path}.class
Both explicit probes= selection and configurator selection resolve this same canonical location.
Implement ExtensionConfigurator and provide a public no-arg constructor:
package org.example.spark;
import io.btrace.core.extensions.ExtensionConfigurator;
import io.btrace.core.extensions.ProbeConfiguration;
import io.btrace.core.extensions.RuntimeEnvironment;
import java.util.Map;
public final class SparkConfigurator implements ExtensionConfigurator {
@Override
public ProbeConfiguration configure(RuntimeEnvironment env, Map<String, String> args) {
ProbeConfiguration config = new ProbeConfiguration();
if (env.hasClass("org.apache.spark.SparkContext")) {
// Running as a Spark driver
config.enable("SparkJobTracer", "SparkStageTracer");
} else if (env.hasClass("org.apache.spark.executor.Executor")) {
// Running as a Spark executor
config.enable("SparkExecutorTracer");
} else {
// Not a Spark JVM — enable nothing
return config;
}
// Honour an explicit output= agent argument if present; default to JFR
String output = args.getOrDefault("output", "jfr");
config.setOutput(output);
return config;
}
}RuntimeEnvironment provides:
hasClass(String)— true if the class is loadable from the application classloadergetSystemProperty(String)/getSystemProperty(String, String)—System.getPropertygetEnv(String)—System.getenvgetClassLoader()— the thread-context classloader (application loader)getMainClassName()— the JVM main class (fromsun.java.command)
ProbeConfiguration lets you:
enable(String... probeNames)— add probes to activatesetOutput(Output)orsetOutput(String)— chooseJFR,FILE, orSTDOUTsetOutputPath(String)— file path forFILEoutputsetProbeParam(String probe, String key, String value)— per-probe parameters
Declare the configurator class in extension.properties:
id=btrace-spark
version=1.0.0
probes=SparkJobTracer,SparkStageTracer,SparkExecutorTracer
configurator=org.example.spark.SparkConfiguratorThe class must be in the extension's implementation classloader (i.e. part of the impl JAR, not the API JAR).
With a configurator in place, operators simply attach the fat agent:
# No probes= needed — the configurator selects the right probes automatically
java -javaagent:my-btrace-agent-fat.jar org.apache.spark.deploy.SparkSubmit ...If the operator does supply probes=, the explicit list takes priority and the
configurator is not called:
# Override: load only SparkJobTracer regardless of what the configurator would choose
java -javaagent:my-btrace-agent-fat.jar=probes=SparkJobTracer org.apache.spark.deploy.SparkSubmit ...- Probe
.classfiles listed underprobes=inextension.properties - Fat agent plugin
bundledProbes {}block stages the probe classes - Configurator has a public no-arg constructor
- Configurator class is in the impl artifact (not the API JAR)
-
configurator=key set inextension.properties -
enable()returns an empty config (not null) when the framework is absent - Unit-tested with a mock
RuntimeEnvironment
- Unit test Impl logic normally (JUnit 5).
- Integration test with real BTrace scripts in
integration-tests. - Verify on supported JDKs (8, 11, 17+).
- Single module using
src/main - Apply
io.btrace.extensionplugin - Set
btraceExtension.id, declare/annotateservices - Configure
shadedPackages; optionally tunerequiredPermissions - Keep API clean (JDK-only) and small
- Build and install ZIP into extensions dir (or embed in fat agent)
- Unit + integration tests pass on supported JDKs
- Consider fat agent packaging for cloud/distributed deployments
- If shipping bundled probes: declare
probes=inextension.propertiesand stage viabundledProbes {} - If probes need auto-selection: implement
ExtensionConfigurator, declareconfigurator=inextension.properties
- Zero-allocation hot paths; avoid boxing.
- Prefer lock-free primitives where possible.
- Lazy init; create objects only when needed.
- Ensure thread-safety; services can be called concurrently.
- Immutable snapshots for queries.
- Clear, minimal public surface.
- Use builders/factories exposed from the service for configuration objects.
// API
@ServiceDescriptor
public interface MetricsService {
HistogramConfigBuilder newHistogramConfig();
HistogramMetric histogram(String name, HistogramConfig cfg);
}
public interface HistogramConfig {}
public interface HistogramConfigBuilder {
HistogramConfigBuilder lowestDiscernibleValue(long v);
HistogramConfigBuilder highestTrackableValue(long v);
HistogramConfigBuilder significantDigits(int d);
HistogramConfig build();
}
// Probe (no `new` in scripts)
@BTrace
class HistoProbe {
@Injected static MetricsService metrics;
}- Script references a type not present in API → export it from the API closure. Keep it in
src/main/javaand add it toadditionalExportsif the plugin cannot infer it.
- Impl does not fully implement the API → keep API and Impl in lockstep.
- Missing/incorrect metadata → ensure
btraceExtension.id/servicesare set or APIs are annotated.
- Missing relocations → add entries under
shadedPackagesfor third-party libraries.
Use a single module with src/main and the BTrace extension plugin to produce clean, isolated, and self-describing extensions. The plugin handles artifact separation, metadata, permissions, shading, packaging, and API export computation, so you can focus on a stable API and solid implementation.
The BTrace agent plugins marketplace provides skills for Claude Code, Codex, and Pi that apply this guide:
btrace-extension-authoring— designing a new extension for a target library, including where@ExternalTypestops and hand-written method handles take over.btrace-legacy-libs-migration— moving an integration off the removedlibs/profile packaging.