kmuto’s blog

はてな社でMackerel CREをやっています。料理と旅行といろんなIT技術

Spring Bootとその計装を試していた

ほかの人に役立つものではなさそうな雑記レベルだが、覚え書きとして。

zero-code計装でSpring Bootを残していたのと、デモ用に作ったアプリケーションがRoRのシングルサービスで分散トレーシングの面白みがあまりないので、何かくっつけてみるかーと練習している。

kmuto.hatenablog.com

連携サービスを作る前にそもそもSpring Boot何もわからん状態なので、まずはチュートリアルから進めていって、rest-serviceで最低限のことはできそうだなという雰囲気までつかんだ。

ネット記事拾い読みよりは公式チュートリアルで進めたい勢。

チュートリアルに基づくRESTfulなアプリケーション作成

Getting Started | Building a RESTful Web Serviceに書かれていることそのまま。

このアプリケーションでは、/greeting?name=値のGETリクエストをされたら、{ id: シーケンシャルな番号, content: "Hello, <name属性値>" }JSONを返したい。nameパラメータが省略されたらWorldになる。

Spring InitializrでArtifactに「restservice」のようにアプリケーション名を付ける(NameやPackage nameも追従する)。さらにDependenciesの「ADD DEPENDENCIES...」をクリックし、「Spring Web」を選択する。「GENERATE」をクリックするとzipがダウンロードされる。

Webは別にMVCになっているわけではないので、モデルとコントローラは自分で書く必要がある。ビューはJackson2ライブラリがJSON化をしてくれるため考えなくていい。

モデルを書く(src/main/java/com/example/restservice/Greeting.java)。

package com.example.restservice;

public record Greeting(long id, String content) {
}

VS Codeでファイルを作るとだいたいできる。モデルはclassでなくrecordにする。ここではモデル内の処理は何もないので、引数にモデルの属性相当を書くだけ。

コントローラを書く(src/main/java/com/example/restservice/GreetingController.java)。

package com.example.restservice;

import java.util.concurrent.atomic.AtomicLong;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class GreetingController {
    private static final String template = "Hello, %s!";
    private final AtomicLong counter = new AtomicLong();

    @GetMapping("/greeting")
    public Greeting greeting(@RequestParam(value = "name", defaultValue = "World") String name) {
        return new Greeting(counter.incrementAndGet(), String.format(template, name));
    }
}

VS Codeでimportまわりは考えずにだいたい入る。

@RestControllerアノテーションをクラスに付ける。これでコントローラであることを表す。ビューじゃなくてドメインオブジェクトを返すことを宣言している。@Controler@ResponseBodyの2つを設定せずに済むショートカットらしい。

private finalでテンプレート文字列と、インクリメンタルカウンタ用オブジェクトを定義。

リクエスト処理のgreetingメソッドで、リクエストパラメータの引数受け取りと、Greetingモデルの返却を記述する。

@GetMappingアノテーションでそのメソッドについて、リクエストパスやHTTPメソッドとの対応処理をする(POSTなら@PostMappingになるし、@RequestMapping(method=GET)のように細かく設定もできる)。

パラメータのほうは、@RequestParamアノテーションでパラメータと引数変数の対応付けや初期値指定をしている。

返却処理はGreetingオブジェクトを作って返すだけ。

アプリケーションコード(RestServiceApplication.java)は試す段階程度ではInitializrが作ったものから変更する必要がない。

package com.example.restservice;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class RestserviceApplication {

    public static void main(String[] args) {
        SpringApplication.run(RestserviceApplication.class, args);
    }

}

起動。

./gradlew bootRun

curlしてみる。

$ curl http://localhost:8080/greeting
{"id":1,"content":"Hello, World!"}
$ curl http://localhost:8080/greeting?name=kmuto
{"id":2,"content":"Hello, kmuto!"}

APIサーバーっぽいのができた。

zero-code計装

トレースを試してみる。

コントローラにエラーも仕込んでおこう。name=errorだったらランタイムエラーを起こすようにした。

    @GetMapping("/greeting")
    public Greeting greeting(@RequestParam(value = "name", defaultValue = "World") String name) {
        if (name.equals("error")) {
            throw new RuntimeException("An error occurred");
        }
        return new Greeting(counter.incrementAndGet(), String.format(template, name));
    }

jarを作る。

./gradlew build

ひとまずJava Agentで見てみよう。opentelemetry-javaagent.jarを持ってきた。デバッグ表示にしたOpenTelemetry Collectorも動かしている(まぁこのくらいならCollector使わずとも普通にloggingに直接出せばいいんじゃないか説ある)。

java -javaagent:./opentelemetry-javaagent.jar -DOtel.service.name=spring-zerocode -jar build/libs/restservice-0.0.1-SNAPSHOT.jar

curlで適当にアクセス。

2025-04-13T14:27:11.591+0900 info    Traces  {"kind": "exporter", "data_type": "traces", "name": "debug", "resource spans": 1, "spans": 1}
2025-04-13T14:27:11.591+0900    info    ResourceSpans #0
Resource SchemaURL: https://opentelemetry.io/schemas/1.24.0
Resource attributes:
     -> host.arch: Str(amd64)
     -> host.name: Str(myhost)
     -> os.description: Str(Linux 6.1.0-30-amd64)
     -> os.type: Str(linux)
     -> process.command_args: Slice(["/usr/lib/jvm/java-17-openjdk-amd64/bin/java","-javaagent:./opentelemetry-javaagent.jar","-DOtel.service.name=spring-zerocode","-jar","build/libs/restservice-0.0.1-SNAPSHOT.jar"])
     -> process.executable.path: Str(/usr/lib/jvm/java-17-openjdk-amd64/bin/java)
     -> process.pid: Int(847474)
     -> process.runtime.description: Str(Debian OpenJDK 64-Bit Server VM 17.0.14+7-Debian-1deb12u1)
     -> process.runtime.name: Str(OpenJDK Runtime Environment)
     -> process.runtime.version: Str(17.0.14+7-Debian-1deb12u1)
     -> service.instance.id: Str(7fbf7d16-60ae-4f6b-a1cf-046dbcc0d586)
     -> service.name: Str(spring-zerocode)
     -> service.version: Str(0.0.1-SNAPSHOT)
     -> telemetry.distro.name: Str(opentelemetry-java-instrumentation)
     -> telemetry.distro.version: Str(2.12.0)
     -> telemetry.sdk.language: Str(java)
     -> telemetry.sdk.name: Str(opentelemetry)
     -> telemetry.sdk.version: Str(1.46.0)
ScopeSpans #0
ScopeSpans SchemaURL: 
InstrumentationScope io.opentelemetry.tomcat-10.0 2.12.0-alpha
Span #0
    Trace ID       : f451f47b0784ecda700c5883a236b828
    Parent ID      : 
    ID             : dd6f9a9a77f5e13c
    Name           : GET /greeting
    Kind           : Server
    Start time     : 2025-04-13 05:27:11.269152171 +0000 UTC
    End time       : 2025-04-13 05:27:11.270719808 +0000 UTC
    Status code    : Unset
    Status message : 
Attributes:
     -> network.peer.address: Str(127.0.0.1)
     -> server.address: Str(localhost)
     -> client.address: Str(127.0.0.1)
     -> url.path: Str(/greeting)
     -> server.port: Int(8080)
     -> http.request.method: Str(GET)
     -> thread.id: Int(44)
     -> http.response.status_code: Int(200)
     -> http.route: Str(/greeting)
     -> user_agent.original: Str(curl/7.88.1)
     -> network.peer.port: Int(38282)
     -> network.protocol.version: Str(1.1)
     -> url.scheme: Str(http)
     -> thread.name: Str(http-nio-8080-exec-6)
    {"kind": "exporter", "data_type": "traces", "name": "debug"}
2025-04-13T14:27:21.594+0900    info    Traces  {"kind": "exporter", "data_type": "traces", "name": "debug", "resource spans": 1, "spans": 1}
2025-04-13T14:27:21.594+0900    info    ResourceSpans #0
Resource SchemaURL: https://opentelemetry.io/schemas/1.24.0
Resource attributes:
     -> host.arch: Str(amd64)
     -> host.name: Str(myhost)
     -> os.description: Str(Linux 6.1.0-30-amd64)
     -> os.type: Str(linux)
     -> process.command_args: Slice(["/usr/lib/jvm/java-17-openjdk-amd64/bin/java","-javaagent:./opentelemetry-javaagent.jar","-DOtel.service.name=spring-zerocode","-jar","build/libs/restservice-0.0.1-SNAPSHOT.jar"])
     -> process.executable.path: Str(/usr/lib/jvm/java-17-openjdk-amd64/bin/java)
     -> process.pid: Int(847474)
     -> process.runtime.description: Str(Debian OpenJDK 64-Bit Server VM 17.0.14+7-Debian-1deb12u1)
     -> process.runtime.name: Str(OpenJDK Runtime Environment)
     -> process.runtime.version: Str(17.0.14+7-Debian-1deb12u1)
     -> service.instance.id: Str(7fbf7d16-60ae-4f6b-a1cf-046dbcc0d586)
     -> service.name: Str(spring-zerocode)
     -> service.version: Str(0.0.1-SNAPSHOT)
     -> telemetry.distro.name: Str(opentelemetry-java-instrumentation)
     -> telemetry.distro.version: Str(2.12.0)
     -> telemetry.sdk.language: Str(java)
     -> telemetry.sdk.name: Str(opentelemetry)
     -> telemetry.sdk.version: Str(1.46.0)
ScopeSpans #0
ScopeSpans SchemaURL: 
InstrumentationScope io.opentelemetry.tomcat-10.0 2.12.0-alpha
Span #0
    Trace ID       : 0709c2241c0588cf8ae5b95210231aa0
    Parent ID      : 
    ID             : a921e14da2f19dfe
    Name           : GET /greeting
    Kind           : Server
    Start time     : 2025-04-13 05:27:16.803467936 +0000 UTC
    End time       : 2025-04-13 05:27:16.804822312 +0000 UTC
    Status code    : Unset
    Status message : 
Attributes:
     -> network.peer.address: Str(127.0.0.1)
     -> server.address: Str(localhost)
     -> client.address: Str(127.0.0.1)
     -> url.path: Str(/greeting)
     -> url.query: Str(name=kmuto)
     -> server.port: Int(8080)
     -> http.request.method: Str(GET)
     -> thread.id: Int(45)
     -> http.response.status_code: Int(200)
     -> http.route: Str(/greeting)
     -> user_agent.original: Str(curl/7.88.1)
     -> network.peer.port: Int(38392)
     -> network.protocol.version: Str(1.1)
     -> url.scheme: Str(http)
     -> thread.name: Str(http-nio-8080-exec-7)
    {"kind": "exporter", "data_type": "traces", "name": "debug"}
2025-04-13T14:27:24.925+0900    info    Logs    {"kind": "exporter", "data_type": "logs", "name": "debug", "resource logs": 1, "log records": 1}
2025-04-13T14:27:24.925+0900    info    ResourceLog #0
Resource SchemaURL: https://opentelemetry.io/schemas/1.24.0
Resource attributes:
     -> host.arch: Str(amd64)
     -> host.name: Str(myhost)
     -> os.description: Str(Linux 6.1.0-30-amd64)
     -> os.type: Str(linux)
     -> process.command_args: Slice(["/usr/lib/jvm/java-17-openjdk-amd64/bin/java","-javaagent:./opentelemetry-javaagent.jar","-DOtel.service.name=spring-zerocode","-jar","build/libs/restservice-0.0.1-SNAPSHOT.jar"])
     -> process.executable.path: Str(/usr/lib/jvm/java-17-openjdk-amd64/bin/java)
     -> process.pid: Int(847474)
     -> process.runtime.description: Str(Debian OpenJDK 64-Bit Server VM 17.0.14+7-Debian-1deb12u1)
     -> process.runtime.name: Str(OpenJDK Runtime Environment)
     -> process.runtime.version: Str(17.0.14+7-Debian-1deb12u1)
     -> service.instance.id: Str(7fbf7d16-60ae-4f6b-a1cf-046dbcc0d586)
     -> service.name: Str(spring-zerocode)
     -> service.version: Str(0.0.1-SNAPSHOT)
     -> telemetry.distro.name: Str(opentelemetry-java-instrumentation)
     -> telemetry.distro.version: Str(2.12.0)
     -> telemetry.sdk.language: Str(java)
     -> telemetry.sdk.name: Str(opentelemetry)
     -> telemetry.sdk.version: Str(1.46.0)
ScopeLogs #0
ScopeLogs SchemaURL: 
InstrumentationScope org.apache.catalina.core.ContainerBase.[Tomcat].[localhost].[/].[dispatcherServlet] 
LogRecord #0
ObservedTimestamp: 2025-04-13 05:27:24.405389485 +0000 UTC
Timestamp: 2025-04-13 05:27:24.405 +0000 UTC
SeverityText: SEVERE
SeverityNumber: Error(17)
Body: Str(Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed: java.lang.RuntimeException: An error occurred] with root cause)
Attributes:
     -> exception.message: Str(An error occurred)
     -> exception.stacktrace: Str(java.lang.RuntimeException: An error occurred
    at com.example.restservice.GreetingController.greeting(GreetingController.java:18)
    ...
    at java.base/java.lang.Thread.run(Thread.java:840)
)
     -> exception.type: Str(java.lang.RuntimeException)
Trace ID: e708f6f8d9877efc26ac854abc78a816
Span ID: 889d1cb3bd67b211
Flags: 1
    {"kind": "exporter", "data_type": "logs", "name": "debug"}
2025-04-13T14:27:26.595+0900    info    Traces  {"kind": "exporter", "data_type": "traces", "name": "debug", "resource spans": 1, "spans": 1}
2025-04-13T14:27:26.595+0900    info    ResourceSpans #0
Resource SchemaURL: https://opentelemetry.io/schemas/1.24.0
Resource attributes:
     -> host.arch: Str(amd64)
     -> host.name: Str(myhost)
     -> os.description: Str(Linux 6.1.0-30-amd64)
     -> os.type: Str(linux)
     -> process.command_args: Slice(["/usr/lib/jvm/java-17-openjdk-amd64/bin/java","-javaagent:./opentelemetry-javaagent.jar","-DOtel.service.name=spring-zerocode","-jar","build/libs/restservice-0.0.1-SNAPSHOT.jar"])
     -> process.executable.path: Str(/usr/lib/jvm/java-17-openjdk-amd64/bin/java)
     -> process.pid: Int(847474)
     -> process.runtime.description: Str(Debian OpenJDK 64-Bit Server VM 17.0.14+7-Debian-1deb12u1)
     -> process.runtime.name: Str(OpenJDK Runtime Environment)
     -> process.runtime.version: Str(17.0.14+7-Debian-1deb12u1)
     -> service.instance.id: Str(7fbf7d16-60ae-4f6b-a1cf-046dbcc0d586)
     -> service.name: Str(spring-zerocode)
     -> service.version: Str(0.0.1-SNAPSHOT)
     -> telemetry.distro.name: Str(opentelemetry-java-instrumentation)
     -> telemetry.distro.version: Str(2.12.0)
     -> telemetry.sdk.language: Str(java)
     -> telemetry.sdk.name: Str(opentelemetry)
     -> telemetry.sdk.version: Str(1.46.0)
ScopeSpans #0
ScopeSpans SchemaURL: 
InstrumentationScope io.opentelemetry.tomcat-10.0 2.12.0-alpha
Span #0
    Trace ID       : e708f6f8d9877efc26ac854abc78a816
    Parent ID      : 
    ID             : 889d1cb3bd67b211
    Name           : GET /greeting
    Kind           : Server
    Start time     : 2025-04-13 05:27:24.404152261 +0000 UTC
    End time       : 2025-04-13 05:27:24.407718876 +0000 UTC
    Status code    : Error
    Status message : 
Attributes:
     -> network.peer.address: Str(127.0.0.1)
     -> server.address: Str(localhost)
     -> client.address: Str(127.0.0.1)
     -> url.path: Str(/greeting)
     -> error.type: Str(500)
     -> url.query: Str(name=error)
     -> server.port: Int(8080)
     -> http.request.method: Str(GET)
     -> thread.id: Int(46)
     -> http.response.status_code: Int(500)
     -> http.route: Str(/greeting)
     -> user_agent.original: Str(curl/7.88.1)
     -> network.peer.port: Int(40870)
     -> network.protocol.version: Str(1.1)
     -> url.scheme: Str(http)
     -> thread.name: Str(http-nio-8080-exec-8)
Events:
SpanEvent #0
     -> Name: exception
     -> Timestamp: 2025-04-13 05:27:24.407653797 +0000 UTC
     -> DroppedAttributesCount: 0
     -> Attributes::
          -> exception.message: Str(An error occurred)
          -> exception.type: Str(java.lang.RuntimeException)
          -> exception.stacktrace: Str(java.lang.RuntimeException: An error occurred
    at com.example.restservice.GreetingController.greeting(GreetingController.java:18)
    ...
    at java.base/java.lang.Thread.run(Thread.java:840)
)
    {"kind": "exporter", "data_type": "traces", "name": "debug"}

手動計装

スパンを作ってみる。

OTel SDKを依存に組み込む(build.gradle)。

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-web'
    implementation 'io.opentelemetry:opentelemetry-api:1.46.0'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}

バージョンは使っているJava Agentのものと合わせる。

コントローラに仕込んでみる。

package com.example.restservice;

import java.util.concurrent.atomic.AtomicLong;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import io.opentelemetry.api.GlobalOpenTelemetry;
import io.opentelemetry.api.trace.Span;
import io.opentelemetry.api.trace.Tracer;

@RestController
public class GreetingController {
    private static final Tracer tracer = GlobalOpenTelemetry.getTracer("com.example.restservice");
    private static final String template = "Hello, %s!";
    private final AtomicLong counter = new AtomicLong();

    @GetMapping("/greeting")
    public Greeting greeting(@RequestParam(value = "name", defaultValue = "World") String name) {
        Span span = tracer.spanBuilder("greeting").startSpan();
        span.setAttribute("name", name);
        span.setAttribute("counter", counter.get());
        try {
            if (name.equals("error")) {
                throw new RuntimeException("An error occurred");
            }
            return new Greeting(counter.incrementAndGet(), String.format(template, name));
        } finally {
            span.end();
        }
    }
}

importはCoPilotのクイックフィクスに任せた。getTracerの引数はmust not be nullだが、ユニークでありさえすればよさそう。

メソッド内では取得したtracerに基づいてスパンを作る。

copeSpans #0
ScopeSpans SchemaURL: 
InstrumentationScope io.opentelemetry.tomcat-10.0 2.12.0-alpha
Span #0
    Trace ID       : a2ea4cea68f42280320ce554034a6e69
    Parent ID      : 
    ID             : 2ff0459bc123f734
    Name           : GET /greeting
    Kind           : Server
    Start time     : 2025-04-13 07:23:54.219432765 +0000 UTC
    End time       : 2025-04-13 07:23:54.335554619 +0000 UTC
    Status code    : Unset
    Status message : 
Attributes:
     -> network.peer.address: Str(127.0.0.1)
     -> server.address: Str(localhost)
     -> client.address: Str(127.0.0.1)
     -> url.path: Str(/greeting)
     -> url.query: Str(name=kmuto)
     -> server.port: Int(8080)
     -> http.request.method: Str(GET)
     -> thread.id: Int(39)
     -> http.response.status_code: Int(200)
     -> http.route: Str(/greeting)
     -> user_agent.original: Str(curl/7.88.1)
     -> network.peer.port: Int(54680)
     -> network.protocol.version: Str(1.1)
     -> url.scheme: Str(http)
     -> thread.name: Str(http-nio-8080-exec-1)
ScopeSpans #1
ScopeSpans SchemaURL: 
InstrumentationScope com.example.restservice 
Span #0
    Trace ID       : a2ea4cea68f42280320ce554034a6e69
    Parent ID      : 2ff0459bc123f734
    ID             : d8cd6a2ce10f3673
    Name           : greeting
    Kind           : Internal
    Start time     : 2025-04-13 07:23:54.29686414 +0000 UTC
    End time       : 2025-04-13 07:23:54.296936405 +0000 UTC
    Status code    : Unset
    Status message : 
Attributes:
     -> counter: Int(0)
     -> thread.id: Int(39)
     -> name: Str(kmuto)
     -> thread.name: Str(http-nio-8080-exec-1)
    {"kind": "exporter", "data_type": "traces", "name": "debug"}

テスト用とはいえ、だいぶ雑な単位でスパン化してしまった。span.end()を省略してみたところ、虚空に消えてしまうっぽい。try-catch-finallyが微妙な気がしていたところ、try-with-resourcesを使うと読みやすくなるらしい。

package com.example.restservice;

import java.util.concurrent.atomic.AtomicLong;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import io.opentelemetry.api.GlobalOpenTelemetry;
import io.opentelemetry.api.trace.Span;
import io.opentelemetry.api.trace.Tracer;
import io.opentelemetry.context.Scope;

@RestController
public class GreetingController {
    private static final Tracer tracer = GlobalOpenTelemetry.getTracer("com.example.restservice");
    private static final String template = "Hello, %s!";
    private final AtomicLong counter = new AtomicLong();

    @GetMapping("/greeting")
    public Greeting greeting(@RequestParam(value = "name", defaultValue = "World") String name) {
        long counterVal = counter.incrementAndGet();
        Span span = tracer.spanBuilder("greeting")
            .setAttribute("name", name)
            .setAttribute("counter", counterVal)
            .startSpan();
        try (Scope scope = span.makeCurrent()) {
            return greetingImpl(name, counterVal);
        } catch (Exception e) {
            span.recordException(e);
            span.setStatus(io.opentelemetry.api.trace.StatusCode.ERROR);
            throw e; // rethrow the exception after recording it
        } finally {
            span.end();
        }
    }

    private Greeting greetingImpl(String name, long counterVal) {
        if (name.equals("error")) {
            throw new RuntimeException("An error occurred");
        }
        return new Greeting(counterVal, String.format(template, name));
    }
}

雑な切り出しだが、なるほど。makeCurrentで現在のスレッドのコンテキストにスパンが紐づくので、ここから子のHTTP呼び出し、DBトレースなどもこの中に入っていくことになる。

ラッパーのユーティリティメソッドを作ってしまう手もある。

package com.example.restservice;

import java.util.concurrent.atomic.AtomicLong;
import java.util.function.Function;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import io.opentelemetry.api.GlobalOpenTelemetry;
import io.opentelemetry.api.trace.Span;
import io.opentelemetry.api.trace.Tracer;
import io.opentelemetry.context.Scope;

@RestController
public class GreetingController {
    private static final String template = "Hello, %s!";
    private final AtomicLong counter = new AtomicLong();

    @GetMapping("/greeting")
    public Greeting greeting(@RequestParam(value = "name", defaultValue = "World") String name) {
        return tracedSpan("greeting", span -> {
            long counterVal = counter.incrementAndGet();
            span.setAttribute("name", name);
            span.setAttribute("counter", counterVal);
            if (name.equals("error")) {
                throw new RuntimeException("An error occurred");
            }
            return new Greeting(counterVal, String.format(template, name));
        });
    }

    public static <T> T tracedSpan(String name, Function<Span, T> logic) {
        Tracer tracer = GlobalOpenTelemetry.getTracer("com.example.restservice");
        Span span = tracer.spanBuilder(name).startSpan();
        try (Scope scope = span.makeCurrent()) {
            return logic.apply(span);
        } catch (Exception e) {
            span.recordException(e);
            span.setStatus(io.opentelemetry.api.trace.StatusCode.ERROR);
            throw e; // rethrow the exception after recording it
        } finally {
            span.end();
        }
    }
}

これはだいぶわかりやすくなった気がする。

transparentによる引き継ぎ

Rails側ではOTel SDK側でtransparentヘッダーを付加するよう設定されており、Java Agentはリクエストのtransparentを自動解釈して自動で親スパン扱いにして引き継いでくれる。

……というところで今日は時間切れ。

Rails側のアプリケーションと結び付けるために、もうちょっとそれっぽいサービスを実装する必要がありそうだが、とっかかりはできた。