459 lines
18 KiB
Markdown
459 lines
18 KiB
Markdown
# Writing instrumentation
|
|
|
|
**Warning**: The repository is still in the process of migrating to the structure described here.
|
|
|
|
Any time we want to add OpenTelemetry support for a new Java library, e.g., so usage
|
|
of that library has tracing, we must write new instrumentation for that library. Let's
|
|
go over some terms first.
|
|
|
|
**Library instrumentation**: This is logic that creates spans and enriches them with data
|
|
using library-specific monitoring APIs. For example, when instrumenting an RPC library,
|
|
the instrumentation will use some library-specific functionality to listen to events such
|
|
as the start and end of a request and will execute code to start and end spans in these
|
|
listeners. Many of these libraries will provide interception type APIs such as the gRPC
|
|
`ClientInterceptor` or servlet's `Filter`. Others will provide a Java interface whose methods
|
|
correspond to a request, and instrumentation can define an implementation which delegates
|
|
to the standard, wrapping methods with the logic to manage spans. Users will add code to their
|
|
apps that initialize the classes provided by library instrumentation, and the library instrumentation
|
|
can be found inside the user's app itself.
|
|
|
|
Some libraries will have no way of intercepting requests because they only expose static APIs
|
|
and no interception hooks. For these libraries it is not possible to create library
|
|
instrumentation.
|
|
|
|
**Java agent instrumentation**: This is logic that is similar to library instrumentation, but instead
|
|
of a user initializing classes themselves, a Java agent automatically initializes them during
|
|
class loading by manipulating byte code. This allows a user to develop their apps without thinking
|
|
about instrumentation and get it "for free". Often, the agent instrumentation will generate
|
|
bytecode that is more or less identical to what a user would have written themselves in their app.
|
|
|
|
In addition to automatically initializing library instrumentation, agent instrumentation can be used
|
|
for libraries where library instrumentation is not possible, such as `URLConnection`, because it can
|
|
intercept even the JDK's classes. Such libraries will not have library instrumentation but will have
|
|
agent instrumentation.
|
|
|
|
## Folder Structure
|
|
|
|
Refer to some of our existing instrumentations for examples of the folder structure, for example:
|
|
[aws-sdk-2.2](../../instrumentation/aws-sdk/aws-sdk-2.2).
|
|
|
|
When writing new instrumentation, create a directory inside `instrumentation` that corresponds to
|
|
the instrumented library and the oldest version being targeted. Ideally an old version of the
|
|
library is targeted in a way that the instrumentation applies to a large range of versions, but this
|
|
may be restricted by the interception APIs provided by the library.
|
|
|
|
Within the subfolder, create three folders `library` (skip if library instrumentation is not
|
|
possible),`javaagent`, and `testing`.
|
|
|
|
For example, if you are targeting the RPC framework `yarpc` at minimal supported version `1.0`, you would have a
|
|
directory tree like the following:
|
|
|
|
```
|
|
instrumentation ->
|
|
...
|
|
yarpc-1.0 ->
|
|
javaagent
|
|
build.gradle.kts
|
|
library
|
|
build.gradle.kts
|
|
testing
|
|
build.gradle.kts
|
|
metadata.yaml
|
|
```
|
|
|
|
The top level `settings.gradle.kts` file would contain the following (please add in alphabetical order):
|
|
|
|
```kotlin
|
|
include(":instrumentation:yarpc-1.0:javaagent")
|
|
include(":instrumentation:yarpc-1.0:library")
|
|
include(":instrumentation:yarpc-1.0:testing")
|
|
```
|
|
|
|
### Instrumentation metadata.yaml (Experimental)
|
|
|
|
Each module can contain a `metadata.yaml` file that describes the instrumentation. This information
|
|
is then used when generating the [instrumentation-list.yaml](../instrumentation-list.yaml) file.
|
|
The schema for `metadata.yaml` is still in development and may change in the future. See the
|
|
[instrumentation-docs readme](../../instrumentation-docs/readme.md) for more information and the
|
|
latest schema.
|
|
|
|
|
|
### Instrumentation Submodules
|
|
|
|
When writing instrumentation that requires submodules for different versions, the name of each
|
|
submodule must be prefixed with the name of the parent directory (typically the library or
|
|
framework name).
|
|
|
|
As an example, if `yarpc` has instrumentation for two different versions, each version submodule
|
|
must include the `yarpc` prefix before the version:
|
|
|
|
```
|
|
instrumentation ->
|
|
...
|
|
yarpc ->
|
|
yarpc-1.0 ->
|
|
javaagent
|
|
build.gradle.kts
|
|
library
|
|
build.gradle.kts
|
|
testing
|
|
build.gradle.kts
|
|
yarpc-2.0 ->
|
|
javaagent
|
|
build.gradle.kts
|
|
library
|
|
build.gradle.kts
|
|
testing
|
|
build.gradle.kts
|
|
```
|
|
|
|
After creating the submodules, they must be registered in the settings.gradle.kts file. Include each
|
|
submodule explicitly to ensure it is recognized and built as part of the project. For example:
|
|
|
|
```kotlin
|
|
include(":instrumentation:yarpc:yarpc-1.0:javaagent")
|
|
include(":instrumentation:yarpc:yarpc-1.0:library")
|
|
include(":instrumentation:yarpc:yarpc-1.0:testing")
|
|
include(":instrumentation:yarpc:yarpc-2.0:javaagent")
|
|
include(":instrumentation:yarpc:yarpc-2.0:library")
|
|
include(":instrumentation:yarpc:yarpc-2.0:testing")
|
|
```
|
|
|
|
## Writing library instrumentation
|
|
|
|
Start by creating the `build.gradle.kts` file in the `library`
|
|
directory:
|
|
|
|
```kotlin
|
|
plugins {
|
|
id("otel.library-instrumentation")
|
|
}
|
|
```
|
|
|
|
The `otel.library-instrumentation` gradle plugin will apply all the default settings and configure
|
|
build tooling for the library instrumentation module.
|
|
|
|
By convention, OpenTelemetry library instrumentations are centered around `*Telemetry`
|
|
and `*TelemetryBuilder` classes. These two are usually the only public classes in the whole module.
|
|
Keep the amount of public classes and methods as small as possible.
|
|
|
|
Start by creating a `YarpcTelemetry` class:
|
|
|
|
```java
|
|
public final class YarpcTelemetry {
|
|
|
|
public static YarpcTelemetry create(OpenTelemetry openTelemetry) {
|
|
return builder(openTelemetry).build();
|
|
}
|
|
|
|
public static YarpcTelemetryBuilder builder(OpenTelemetry openTelemetry) {
|
|
return new YarpcTelemetryBuilder(openTelemetry);
|
|
}
|
|
|
|
// ...
|
|
|
|
YarpcTelemetry() {}
|
|
|
|
public Interceptor newTracingInterceptor() {
|
|
// ...
|
|
}
|
|
}
|
|
```
|
|
|
|
By convention, the `YarpcTelemetry` class exposes the `create()` and `builder()` methods as the only
|
|
way of constructing a new instance; the constructor must be kept package-private (at most). Most of
|
|
the configuration/construction logic happens in the builder class. Don't expose any other way of
|
|
creating a new instance other than using the builder.
|
|
|
|
The `newTracingInterceptor()` method listed in the example code returns an implementation of one of
|
|
the library interfaces which adds the telemetry. This part might look different for every
|
|
instrumented library: some of them expose interceptor/listener interfaces that can be easily plugged
|
|
into the library, some others have a library interface that you can use to implement a decorator that
|
|
emits telemetry when used.
|
|
|
|
Consider the following builder class:
|
|
|
|
```java
|
|
public final class YarpcTelemetryBuilder {
|
|
|
|
YarpcTelemetryBuilder(OpenTelemetry openTelemetry) {}
|
|
|
|
// ...
|
|
|
|
public YarpcTelemetry build() {
|
|
// ...
|
|
}
|
|
}
|
|
```
|
|
|
|
The builder must have a package-private constructor, so that the only way of creating a new one is
|
|
calling the `YarpcTelemetry#builder()` method and a public `build()` method that will return a new,
|
|
properly configured `YarpcTelemetry` instance.
|
|
|
|
The library instrumentation builders can contain configuration settings that let you customize the
|
|
behavior of the instrumentation. Most of these options are used to configure the
|
|
underlying `Instrumenter` instance that's used to encapsulate the whole telemetry generation
|
|
process.
|
|
|
|
The configuration and usage of the `Instrumenter` class is described in
|
|
[a separate document](using-instrumenter-api.md). In most cases, the `build()`
|
|
method is supposed to create a fully configured `Instrumenter` instance and pass it
|
|
to `YarpcTelemetry`, which in turn can pass it to the interceptor returned
|
|
by `newTracingInterceptor()`. The actual process of configuring an `Instrumenter` and various
|
|
interfaces involved are described in the [`Instrumenter` API doc](using-instrumenter-api.md).
|
|
|
|
## Writing instrumentation tests
|
|
|
|
Once the library instrumentation is completed, add tests to the `testing` module. Start
|
|
by setting up the `build.gradle.kts` file:
|
|
|
|
```kotlin
|
|
plugins {
|
|
id("otel.java-conventions")
|
|
}
|
|
|
|
dependencies {
|
|
api(project(":testing-common"))
|
|
|
|
// ...
|
|
}
|
|
```
|
|
|
|
Tests in the `testing` module describe scenarios that apply to both library and javaagent
|
|
instrumentations, the only difference being how the instrumented library is initialized. In a
|
|
library instrumentation test, there will be code calling into the instrumentation API, while in a
|
|
javaagent instrumentation test it will generally use the underlying library API as is and just rely
|
|
on the javaagent to apply all the necessary bytecode changes automatically.
|
|
|
|
You can use JUnit 5 to test the instrumentation. Start by creating an abstract class with an
|
|
abstract method, for example `configure()`, that returns the instrumented object, such as a client,
|
|
server, or the main class of the instrumented library. See the [JUnit](#junit) section for more
|
|
information.
|
|
|
|
After writing some tests, return to the `library` package and make sure it has
|
|
a `testImplementation` dependency on the `testing` submodule. Then, create a test class that extends
|
|
the abstract test class from `testing`. You should implement the abstract `configure()` method to
|
|
initialize the library using the exposed mechanism to register interceptors/listeners, perhaps a
|
|
method like `registerInterceptor`. You can also wrap the object with the instrumentation decorator.
|
|
Make sure that the test class is marked as a library instrumentation test. Both JUnit and Spock test
|
|
utilities expose a way to specify whether you're running a library or javaagent test. If the tests
|
|
pass, the library instrumentation is working.
|
|
|
|
### JUnit
|
|
|
|
The `testing-common` module exposes several JUnit extensions that facilitate writing instrumentation
|
|
tests. In particular, we'll take a look at `LibraryInstrumentationExtension`
|
|
, `AgentInstrumentationExtension`, and their parent class `InstrumentationExtension`. The extension
|
|
class implements several useful methods, such as `waitAndAssertTraces` and `waitAndAssertMetrics`,
|
|
that you can use in your test cases to verify that the correct telemetry has been produced.
|
|
|
|
Consider the following abstract test case class:
|
|
|
|
```java
|
|
public abstract class AbstractYarpcTest {
|
|
|
|
protected abstract InstrumentationExtension testing();
|
|
|
|
protected abstract Yarpc configure(Yarpc yarpc);
|
|
|
|
@Test
|
|
void testSomething() {
|
|
// ...
|
|
}
|
|
}
|
|
```
|
|
|
|
In addition to the `configure()` method mentioned earlier, you have to add an additional `testing()`
|
|
method that returns an `InstrumentationExtension` and is supposed to be implemented by the extending
|
|
class.
|
|
|
|
The library instrumentation class would look like the following:
|
|
|
|
```java
|
|
class LibraryYarpcTest extends AbstractYarpcTest {
|
|
|
|
@RegisterExtension
|
|
InstrumentationExtension testing = LibraryInstrumentationExtension.create();
|
|
|
|
@Override
|
|
protected InstrumentationExtension testing() {
|
|
return testing;
|
|
}
|
|
|
|
@Override
|
|
protected Yarpc configure(Yarpc yarpc) {
|
|
// register interceptor/listener etc
|
|
}
|
|
}
|
|
```
|
|
|
|
You can use the `@RegisterExtension` annotation to make sure that the instrumentation extension gets
|
|
picked up by JUnit. Then, return the same extension instance in the `testing()` method
|
|
implementation so that it's used in all test scenarios implemented in the abstract class.
|
|
|
|
|
|
## Writing Java agent instrumentation
|
|
|
|
Now that you have working and tested library instrumentation, implement the javaagent
|
|
instrumentation so that the users of the agent do not have to modify their apps to enable telemetry
|
|
for the library.
|
|
|
|
Start with the gradle file to make sure that the `javaagent` submodule has a dependency on
|
|
the `library` submodule and a test dependency on the `testing` submodule.
|
|
|
|
```kotlin
|
|
plugins {
|
|
id("otel.javaagent-instrumentation")
|
|
}
|
|
|
|
dependencies {
|
|
implementation(project(":instrumentation:yarpc-1.0:library"))
|
|
|
|
testImplementation(project(":instrumentation:yarpc-1.0:testing"))
|
|
}
|
|
```
|
|
|
|
All javaagent instrumentation modules should also have the muzzle plugins configured. You can read
|
|
more about how to set this up properly in the [muzzle docs](muzzle.md#muzzle-check-gradle-plugin).
|
|
|
|
Javaagent instrumentation defines matching classes for which bytecode is generated. You often match
|
|
against the class you used in the test for library instrumentation, for example the builder of a
|
|
client. You can also match against the method that creates the builder, for example its constructor.
|
|
Agent instrumentation can inject bytecode to be run before the constructor returns, which would
|
|
invoke, for example,`registerInterceptor` and initialize the instrumentation. Often, the code inside
|
|
the bytecode decorator is identical to the one in the test you wrote above, because the agent does
|
|
the work for initializing the instrumentation library, so a user doesn't have to. You can find a
|
|
detailed explanation of how to implement a javaagent instrumentation
|
|
[here](writing-instrumentation-module.md).
|
|
|
|
Next, add tests for the agent instrumentation. You want to ensure that the instrumentation works
|
|
without the user knowing about the instrumentation.
|
|
|
|
Create a test that extends the base class you wrote earlier but does nothing in the `configure()`
|
|
method. Unlike the library instrumentation, the javaagent instrumentation is supposed to work
|
|
without any explicit user code modification. Add an `AgentInstrumentationExtension` and try running
|
|
tests in this class. All tests should pass.
|
|
|
|
Note that all the tests inside the `javaagent` module are run using the `agent-for-testing`
|
|
javaagent, with the instrumentation being loaded as an extension. This is done to perform the same
|
|
bytecode instrumentation as when the agent is run against a normal app, and means that the javaagent
|
|
instrumentation will be hidden inside the javaagent (loaded by the `AgentClassLoader`) and will not
|
|
be directly accessible in your test code. Make sure not to use the classes from the javaagent
|
|
instrumentation in your test code. If for some reason you need to write unit tests for the javaagent
|
|
code, see [this section](#writing-java-agent-unit-tests).
|
|
|
|
## Additional considerations regarding instrumentations
|
|
|
|
### Instrumenting code that is not available as a Maven dependency
|
|
|
|
If an instrumented server or library jar isn't available in any public Maven repository you can
|
|
create a module with stub classes that define only the methods that you need to write the
|
|
instrumentation. Methods in these stub classes can just `throw new UnsupportedOperationException()`;
|
|
these classes are only used to compile the advice classes and won't be packaged into the agent.
|
|
During runtime, real classes from instrumented server or library will be used.
|
|
|
|
Start by creating a module called `compile-stub` and add a `build.gradle.kts` file with the
|
|
following content:
|
|
|
|
```kotlin
|
|
plugins {
|
|
id("otel.java-conventions")
|
|
}
|
|
```
|
|
|
|
In the `javaagent` module add a `compileOnly` dependency to the newly created stub module:
|
|
|
|
```kotlin
|
|
compileOnly(project(":instrumentation:yarpc-1.0:compile-stub"))
|
|
```
|
|
|
|
Now you can use your stub classes inside the javaagent instrumentation.
|
|
|
|
### Coordinating different `InstrumentationModule`s
|
|
|
|
When you need to share some classes between different `InstrumentationModule`s and communicate
|
|
between different instrumentations (which might be injected/loaded into different class loaders),
|
|
you can add instrumentation-specific bootstrap module that contains all the common classes.
|
|
That way you can use these shared, globally available utilities to communicate between different
|
|
instrumentation modules.
|
|
|
|
Some examples of this include:
|
|
|
|
- Application server instrumentations communicating with Servlet API instrumentations.
|
|
- Different high-level Kafka consumer instrumentations suppressing the low-level `kafka-clients`
|
|
instrumentation.
|
|
|
|
Create a module named `bootstrap` and add a `build.gradle.kts` file with the following content:
|
|
|
|
```kotlin
|
|
plugins {
|
|
id("otel.javaagent-bootstrap")
|
|
}
|
|
```
|
|
|
|
In all `javaagent` modules that need to access the new shared module, add a `compileOnly`
|
|
dependency:
|
|
|
|
```kotlin
|
|
compileOnly(project(":instrumentation:yarpc-1.0:bootstrap"))
|
|
```
|
|
|
|
All classes from the newly added bootstrap module will be loaded by the bootstrap module and
|
|
globally available within the JVM. **IMPORTANT: Note that you _cannot_ use any third-party libraries
|
|
here, including the instrumented library - you can only use JDK and OpenTelemetry API classes.**
|
|
|
|
### Common Modules
|
|
|
|
When creating a common module shared among different instrumentations, the naming convention should
|
|
include a version suffix that matches the major/minor version of the instrumented library specified
|
|
in the common module's `build.gradle.kts`.
|
|
|
|
For example, if the common module's Gradle file contains the following dependency:
|
|
|
|
```kotlin
|
|
dependencies {
|
|
compileOnly("org.yarpc.client:rest:5.0.0")
|
|
}
|
|
```
|
|
|
|
Then the module should be named using the suffix `yarp-common-5.0`.
|
|
|
|
If the common module does not have a direct dependency on the instrumented library, no version
|
|
suffix is required. Examples of such cases include modules named `lettuce-common` and
|
|
`netty-common`.
|
|
|
|
## Writing Java agent unit tests
|
|
|
|
As mentioned before, tests in the `javaagent` module cannot access the javaagent instrumentation
|
|
classes directly because of class loader separation - the javaagent classes are hidden and not
|
|
accessible from the instrumented application code.
|
|
|
|
Ideally javaagent instrumentation is just a thin wrapper over library instrumentation, and so there
|
|
is no need to write unit tests that directly access the javaagent instrumentation classes.
|
|
|
|
If you still want to write a unit test against javaagent instrumentation, add another module
|
|
named `javaagent-unit-tests`. Continuing with the example above:
|
|
|
|
```
|
|
instrumentation ->
|
|
...
|
|
yarpc-1.0 ->
|
|
javaagent
|
|
build.gradle.kts
|
|
javaagent-unit-tests
|
|
build.gradle.kts
|
|
...
|
|
```
|
|
|
|
Set up the unit tests project as a standard Java project:
|
|
|
|
```kotlin
|
|
plugins {
|
|
id("otel.java-conventions")
|
|
}
|
|
|
|
dependencies {
|
|
testImplementation(project(":instrumentation:yarpc-1.0:javaagent"))
|
|
}
|
|
```
|