【AI生成童話】手動計装売りの少女

晦日の夜でした。 街は「自動計装」の煌びやかなネオンで溢れていました。

「エージェントを入れるだけで、すべてが見えるようになりますよ!」 「コードを一行も書かずに、分散トレーシングが完成します!」 そんな威勢のいい声が響く中、少女は街の片隅で、「手動計装SDK」を抱えて立っていました。

「手動計装はいりませんか……。大切なドメインロジックに、自分でスパンを仕込んでみませんか……」 しかし、誰一人足を止める者はいません。 街を行くエンジニアたちは、ボタン一つで生成された「全自動のダッシュボード」に映し出される、サイケデリックなスパンの洪水に夢中だったからです。 彼女の抱える「手動計装」は、テストも書き、プルリクレビューという茨の道を通り、デプロイの手間がかかります。 地味で、流行りではありません。

少女は、ネオンの届かない路地裏へと歩を進めました。 そこには、派手な広告に惹かれて「自動計装全部盛り」を導入したものの、結局、原因不明の「なんか遅い」という深い闇の中で凍えているエンジニアたちがいました。

彼らの足元には、自動計装が吐き出した「解読不能な数万のトレース」という名の、冷たく濁った泥水が溢れかえっていました。 「サンプリング……サンプリング設定を変えろ……」とうわ言のように呟きながら、汲み出しても増え続けるデータの濁流に、彼らはもう、何が真実かを見失いかけていたのです。

少女は寒さに震えていました。 ついに耐えかねた少女は、リポジトリから「手動計装SDK」を取り出し、エディタを立ち上げて、シュッと一行のコードを走らせました。

一回目。シュッ。

その一行が書き込まれた瞬間、暗闇だったシステムの中に、ポッと温かな「ビジネスロジックの火」が灯りました。

それは自動計装が出す無機質なHTTPリクエストの羅列ではありません。 自分たちが魂を込めて書いた checkInventory(在庫チェック) という名前の付いた、意志のある火でした。 「ここで実は処理が詰まっているのだ」という、開発者の深い洞察が宿った光でした。

「ああ、なんて分かりやすいんだろう。どのマイクロサービスが実は怠けているか、手に取るようにわかる……」 泥水に浸かっていたエンジニアたちが、一人、また一人と顔を上げました。

二回目。シュッ。

次に少女がコードを書き加えると、今度は美しい「カスタムアトリビュート」の光が広がりました。

ただの 200 OK ではなく、そこには user_tier: goldorder_value: 50000 といった、ビジネスにとって本当に意味のあるタグが刻まれていました。 「これよ! コレクターのYAMLを100行いじるより、この一行を書くほうがずっと正体に近づけるわ!」

三回目。シュッ。

少女は祈るような気持ちで、内心一番やべぇと思っていたメソッドにスパンを仕込みました。 すると、どうでしょう。

そこには、壮大な「システム全体への深い理解」が、オーロラのように広がりました。 それは、自動計装が垂れ流す膨大なデータの洪水から、1ミリグラムの真実を探し出す不毛な砂金採りではありません。 自分の手で「ここが重要だ」と定義した場所だけが、ダイヤモンドのように一点の曇りもなく輝いている光景でした。

「お願い、この光を消さないで! 複雑な設定や、ブラウザのメモリを食い尽くすスパンの山に、私たちの本質を埋もれさせないで!」

一人のエンジニアが、少女の持っていたSDKをそっと受け取りました。 「君の言う通りだ。全部を闇雲にダンプするんじゃなくて、僕たちが大切にしているロジックに、自分たちで火を灯すべきだったんだね」

彼は、少女と一緒にエディタを開きました。 彼らが共同でコードを書き加えると、冷え切っていたサーバーサイドの空気は一変し、まるで暖炉に火が入ったかのような温もりに包まれました。

翌朝。

少女は、エンジニアたちと一緒にダッシュボードを囲み、温かいコーヒーを飲みながら笑っていました。 画面に映っていたのは、もはや「何が出ているか分からない複雑なグラフ」ではありません。 彼らが自分たちの手で計装し、自分たちのユビキタス言語で名付けた、「システムの今の健康状態」がひと目でわかる、美しく整理された景色でした。

「まずは自動計装で全体をうっすら照らして、ここぞという場所には僕たちが手動で強い光を灯す。これが一番いいやり方だったんだ」

かつて絶望の中にいたエンジニアたちは、もうノイズデータの吹雪に怯えることはありません。 少女が教えた「一本のマッチで、真実を照らす方法」を、誰もが自分のコードの中に持つようになったからです。

群れに埋もれた『名もなきホスト』へ。サーバー管理者の愛が紡ぐ、新しい絆の物語

ホストたちが、その日のメトリックをもとに、カスタムダッシュボードに日記を書いてくれたよ!

ゼラヴィオス様の日記

星野ひまりの全力疾走日記!

これは Mackerel - Qiita Advent Calendar 2025 - Qiita 17日目の記事でした。Mackerel CRE id:yohfee より。

君のホストをかわいがってあげてね!

続きを読む

Mackerel APM のデモを支えなかった技術

Mackerel Advent Calendar 2025 9日目だよ〜

qiita.com

こんにちは、Mackerel CRE の id:yohfee です。 11月に開発チームから CRE チームに移籍していました。 改めましてよろしくお願いいたします。

今年は 開発者コミュニティのみなさんと交流した1年 — Mackerel 2025年のイベント活動ふりかえり - Mackerel ブログ #mackerelio にもある通り、カンファレンススポンサーとして何度かブース出展し、 デモとして実際の Mackerel APM の画面をたくさんの方に触れていただくことができました。

最初の Go Conference 2025 では GitHub - mackerelio-labs/mackerel-demo-gocon-2025: Mackerel Demo Go Conference 2025 を用意したのですが、 あらかじめトレースを投稿しておく準備が必要なことや、Go であるため他のカンファレンスには持っていきづらいなど、継続的に利用するには課題が多くありました。

ということで、まずは以下のコンセプトのものを用意できないかと考えました。

  • 継続的・自動的にトレースを投稿し続けること
  • 構成が異なる複数サービスにまたがる分散トレーシングであること
  • パフォーマンスやエラーなどの典型的な課題を見所として紹介し、改善や解決のイメージを得てもらえること

とはいえ、見所を実装したサービスを複数運用して、常にアクセスされ続ける環境を保持するのは大変です。 これまで普段お見せしていたデモオーガニゼーションや、先日公開した デモ体験 が、 継続的・自動的にトレースを投稿し続けてはいるものの、単一サービスのみで、見所も限られているのは、そのあたりも理由として挙げられます。

そこで思いついたのが、実際に稼働するサービスから正直にトレースデータを生成しなくても、任意のデータをでっち上げて送り付ければいいじゃんということです。 こうして、Azure Functions で5分おきに向こう5分間の3サービス分のトレースを捏造して Mackerel に投稿する仕組みが爆誕しました。

このエントリのタイトルから察せられる通り、実用には至らなかったので、年末お焚き上げで供養します。

まずは準備のコードから。複数サービスを定義するために、Resource に任意の属性を設定して、それぞれに TracerProvider を割り当てます。 その際 ActivitySource を DI に登録しておくのがポイントです。

open System

// トレースを生成する処理の実装を DI するためのインターフェース
type IRunner =
    interface
        abstract member Run: now: DateTime -> unit
    end

open System
open Microsoft.Azure.Functions.Worker

// Azure Functions の TimerTrigger のエントリーポイント
// DI された処理を回すだけ
type TimerFunction(runners: IRunner seq) =

    [<Function(nameof (TimerFunction))>]
    member _.Run([<TimerTrigger("0 */5 * * * *")>] _timer: TimerInfo) =
        for i = 0 to 4 do
            let now = DateTime.UtcNow.AddMinutes i
            runners |> Seq.iter _.Run(now)

open System
open System.Diagnostics
open System.Runtime.CompilerServices
open Microsoft.Azure.Functions.Worker
open Microsoft.Azure.Functions.Worker.Builder
open Microsoft.Extensions.DependencyInjection
open Microsoft.Extensions.Hosting
open OpenTelemetry
open OpenTelemetry.Exporter
open OpenTelemetry.Resources
open OpenTelemetry.Trace

let configureServices (builder: FunctionsApplicationBuilder) =
    let mackerelApiKey = Environment.GetEnvironmentVariable "MACKEREL_APIKEY"
    let serviceNamespace = "mackerel-demo"

    let registerTracerProvider (resourceBuilder: unit -> ResourceBuilder) name version attributes =
        let source = new ActivitySource(sprintf "%s-%s" name version)

        builder.Services.AddKeyedSingleton(source.Name, source) |> ignore

        Sdk
            .CreateTracerProviderBuilder()
            .SetResourceBuilder(
                resourceBuilder()
                    .AddAttributes(attributes)
                    .AddService(name, serviceNamespace, version)
            )
            .AddSource(source.Name)
            .SetSampler(AlwaysOnSampler())
            .AddOtlpExporter(fun options ->
                options.Endpoint <- Uri "https://otlp-vaxila.mackerelio.com/v1/traces"
                options.Protocol <- OtlpExportProtocol.HttpProtobuf
                options.Headers <- $"Mackerel-Api-Key={mackerelApiKey},Accept=*/*")
            .Build()
        |> builder.Services.AddSingleton
        |> ignore

    // Ruby の shop サービス V2
    registerTracerProvider
        ResourceBuilder.CreateEmpty
        "shop"
        "2.0"
        (dict [
            "deployment.environment.name", "production"
            "process.runtime.name", "ruby"
            "process.runtime.version", "3.3.1"
            "telemetry.sdk.language", "ruby"
            "telemetry.sdk.name", "opentelemetry"
            "telemetry.sdk.version", "1.5.0"
        ])

    // Ruby の shop サービス V3
    registerTracerProvider
        ResourceBuilder.CreateEmpty
        "shop"
        "3.0"
        (dict [
            "deployment.environment.name", "production"
            "process.runtime.name", "ruby"
            "process.runtime.version", "3.4.2"
            "telemetry.sdk.language", "ruby"
            "telemetry.sdk.name", "opentelemetry"
            "telemetry.sdk.version", "1.6.0"
        ])

    // .NET の catalog サービス
    registerTracerProvider
        ResourceBuilder.CreateDefault
        "catalog"
        "1.1"
        (dict [ "deployment.environment.name", "production" ])

    // Java の basket サービス
    registerTracerProvider
        ResourceBuilder.CreateEmpty
        "basket"
        "2.51"
        (dict [
            "deployment.environment.name", "production"
            "host.arch", "amd64"
            "os.description", "Windows Server 2019 Datacenter"
            "os.type", "windows"
            "process.runtime.description", "Microsoft OpenJDK 64-Bit Server VM 17.0.16+8-LTS"
            "process.runtime.name", "OpenJDK Runtime Environment"
            "process.runtime.version", "17.0.16+8-LTS"
            "telemetry.distro.name", "opentelemetry-java-instrumentation"
            "telemetry.distro.version", "2.21.0"
            "telemetry.sdk.language", "java"
            "telemetry.sdk.name", "opentelemetry"
            "telemetry.sdk.version", "1.55.0"
        ])

    builder.Services
        .AddApplicationInsightsTelemetryWorkerService()
        .ConfigureFunctionsApplicationInsights()
        .AddTransient<IRunner, Index>()
        .AddTransient<IRunner, UpdateBasket>()
//      .AddTransient<IRunner, などなど>()

[<Extension>]
type FunctionsApplicationBuilderExtensions() =
    [<Extension>]
    static member ConfigureServices(builder: FunctionsApplicationBuilder) =
        configureServices builder |> ignore
        builder

[<EntryPoint>]
let main args =
    FunctionsApplication
        .CreateBuilder(args)
        .ConfigureFunctionsWebApplication()
        .ConfigureServices()
        .Build()
        .Run()

    0

改めて眺めると、処理が増えてくると実行時間が気になってくるので、一つのエントリーポイントでまとめて実行するよりも、処理ごとにエントリーポイントを分けることを考えるのもよさそうです。

また、トレースを生成する処理は次のように、DI された ActivitySourceStartActivity で、任意の属性を設定してスパンを開始し、開始終了時刻も辻褄が合う程度に適当に指定します。 子スパンとしたいものには親としたいスパンのコンテキストを渡すことで親子関係を表現できます。

open System
open System.Diagnostics
open Microsoft.Extensions.DependencyInjection

type Index
    (
        [<FromKeyedServices("shop-2.0")>] shop: ActivitySource,
        [<FromKeyedServices("catalog-1.1")>] catalog: ActivitySource,
        [<FromKeyedServices("basket-2.51")>] basket: ActivitySource
    ) =
    interface IRunner with
        member _.Run(now: DateTime) =
            let now = Random.Shared.NextDouble() |> (*) 60. |> now.AddSeconds

            use root =
                shop
                    .StartActivity(
                        "GET /",
                        ActivityKind.Server,
                        ActivityContext(
                            ActivityTraceId.CreateRandom(),
                            ActivitySpanId.CreateRandom(),
                            ActivityTraceFlags.Recorded
                        ),
                        dict [
                            "http.request.method", box "GET"
                            "http.response.status_code", 200
                            "http.route", "/"
                            "network.protocol.version", "2"
                            "server.address", "shop.example.com"
                            "server.port", 7276
                            "url.path", "/"
                            "url.scheme", "https"
                            "user_agent.original",
                            "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36 Edg/142.0.0.0"
                        ]
                    )
                    .SetStartTime(now)
                    .SetEndTime(now.AddMilliseconds 23.87)

            let t = now.AddMilliseconds 3.47

            use catalogClient =
                shop
                    .StartActivity(
                        "GET",
                        ActivityKind.Client,
                        root.Context,
                        dict [
                            "http.request.method", box "GET"
                            "http.response.status_code", 200
                            "network.protocol.version", 1.1
                            "server.address", "catalog.example.com"
                            "server.port", 7241
                            "url.full", "https://catalog.example.com:7241/api/v1/catalog/items/type/all"
                        ]
                    )
                    .SetStartTime(t)
                    .SetEndTime(t.AddMilliseconds 18.04)

            let t = now.AddMilliseconds 8.69

            use catalogRoot =
                catalog
                    .StartActivity(
                        "GET /api/v1/catalog/items/type/all",
                        ActivityKind.Server,
                        catalogClient.Context,
                        dict [
                            "http.request.method", box "GET"
                            "http.response.status_code", 200
                            "http.route", "/api/v1/catalog/items/type/all"
                            "network.protocol.version", 1.1
                            "server.address", "catalog.example.com"
                            "server.port", 7241
                            "url.path", "/api/v1/catalog/items/type/all"
                            "url.scheme", "https"
                        ]
                    )
                    .SetStartTime(t)
                    .SetEndTime(t.AddMilliseconds 12.7)

            let t = now.AddMilliseconds 16.86

            let catalogQuery =
                catalog
                    .StartActivity(
                        "catalogdb",
                        ActivityKind.Client,
                        catalogRoot.Context,
                        dict [
                            "db.connection_string", box "Host=localhost;Port=52288;Username=postgres;Database=catalogdb"
                            "db.name", "catalogdb"
                            "db.statement",
                            """SELECT c."Id", c."AvailableStock", c."CatalogBrandId", c."CatalogTypeId", c."Description", c."MaxStockThreshold", c."Name", c."OnReorder", c."PictureFileName", c."Price", c."RestockThreshold"
FROM "Catalog" AS c
ORDER BY c."Id"
LIMIT @__pageSize + 1"""
                            "db.system", "postgresql"
                            "db.user", "postgres"
                            "net.transport", "ip_tcp"
                        ]
                    )
                    .SetStartTime(t)
                    .SetEndTime(t.AddMilliseconds 2.25)
                    .AddEvent(ActivityEvent("received-first-response", t.AddMilliseconds 1.79))

            catalogQuery.Stop()
            catalogRoot.Stop()
            catalogClient.Stop()

            let t = now.AddMilliseconds 3.49

            use basketClient =
                shop
                    .StartActivity(
                        "BasketApi.Basket/GetBasketById",
                        ActivityKind.Client,
                        root.Context,
                        dict [
                            "rpc.grpc.status_code", box 0
                            "rpc.method", "GetBasketById"
                            "rpc.service", "BasketApi.Basket"
                            "rpc.system", "grpc"
                            "server.address", "basket.example.com"
                            "server.port", 443
                        ]
                    )
                    .SetStartTime(t)
                    .SetEndTime(t.AddMilliseconds 8.76)

            let t = now.AddMilliseconds 9.07

            use basketRoot =
                basket
                    .StartActivity(
                        "POST /BasketApi.Basket/GetBasketById",
                        ActivityKind.Server,
                        basketClient.Context,
                        dict [
                            "grpc.method", box "/BasketApi.Basket/GetBasketById"
                            "grpc.status_code", 0
                            "http.request.method", "POST"
                            "http.response.status_code", 200
                            "http.route", "/BasketApi.Basket/GetBasketById"
                            "network.protocol.version", 2
                            "server.address", "basket.example.com"
                            "url.path", "/BasketApi.Basket/GetBasketById"
                            "url.scheme", "https"
                        ]
                    )
                    .SetStartTime(t)
                    .SetEndTime(t.AddMilliseconds 2.81)

            let t = now.AddMilliseconds 10.43

            use basketQuery =
                basket
                    .StartActivity(
                        "GET",
                        ActivityKind.Client,
                        basketRoot.Context,
                        dict [
                            "db.redis.database_index", box 0
                            "db.redis.flags", "None"
                            "db.statement", "GET"
                            "db.system", "redis"
                        ]
                    )
                    .SetStartTime(t)
                    .SetEndTime(t.AddMilliseconds 1.35)

            basketQuery.Stop()
            basketRoot.Stop()
            basketClient.Stop()

            root.Stop()

これで投稿されるトレースはこんな感じになります。

リソースが設定した通り Ruby の shop サービス V2 になっている

属性が設定した通り gRPC の呼び出しになっている

さて、まぁまぁいい感じかとは思いますが、いちトレースでこの記述量だと大変ですね。 なので、去年のMackerel Advent Calendar 2024 で紹介した Mackerel DSLを作ろう - @yohfee.blog! のように、コンピュテーション式で DSL を作り始めたら、そっちの方が楽しくなって、見所の盛り込みが次の会に間に合わなくなってしまいました。というオチ。

Dify でお手軽にヘルプチャットボットを作る

最近ワケあってDifyを触っている。お仕事でも使いどころ無いかな~ということで、一番手っ取り早そうなヘルプページを教えてくれる君を作ってみる。

dify.ai

MackerelのヘルプページはGitHubMarkdownで公開されているので、こいつをDifyのナレッジにぶち込めばいいだろう。ただし今回はフリープランで50ドキュメントまでしか登録できないため、トレーシング以下のみをインポートすることに。

docs.dify.ai

特に難しいことはしないので、シンプルなチャットボットでアプリを作成した。コンテキストに上記のナレッジを設定し、それっぽいシステムプロンプトを生成。モデルはGeminiから適当に。

公開方法はいろいろあるけど、実際にやるならサービス内に埋め込むのが一番便利そうだから今回はこれで。

docs.dify.ai

スクリプトタグをこの記事に書いてあるのでページ右下にそれっぽいボタンが生えているはず。記事を書いているプレビューの時点でも動いてる。スクロールについてこないのははてなブログとのCSS的な相性とかかな(未確認)。

エンジニアじゃなくてもできるのはよさそうだけど、ドキュメント量が多いと手作業でポチポチするのも面倒なので、CIでAPIを使ってガッとやると便利だろう。

なおGeminiのトークンが心配になったらこの記事では試せないようにするか、記事自体が消滅するかもなのでご了承ください。

.NET Framework 4.8 でも Mackarel の APM を使いたい

世の中には .NET Framework で動いているシステムがまだまだ存在しているだろうということで、そういえばこれらは OpenTelemetry で計装できるんだろうかと思ったのでやってみる。

話を単純にするために下記のような、各拠点に Windows Form デスクトップアプリが配置され、本部に ASP.NET の Web APISQL Server があるという、いかにもありそうな構成を考える。

その前にまずお試しということで .NET Framework 4.8 のコンソールアプリケーションからトレースとメトリックを Mackarel に直接送信してみようとしたところ、.NET Framework では HTTP/2 での通信が標準的な方法ではできないようだった。Mackerel はトレースは HTTP なので問題ないが、メトリックは gRPC なので単純にやるには難しいことがわかった。

ということで、特に工夫をすることもなく OpenTelemetry Collector を利用して、次のような構成にするとよかろうか。

OpenTelemetry Collector

設定は Mackerel のヘルプページを参考に

ポイントはこんなとこ

  • 今回は Docker で動かすので、ホストからのリクエストを受け付けられるようにエンドポイントに 0.0.0.0 を指定
  • .NET Framework アプリケーションからはトレースもメトリックも HTTP で送られてくるので grpc はリッスンしなくていい
  • Mackerel にはトレースは HTTP のまま、メトリックは gRPC で送る
receivers:
  otlp:
    protocols:
      http:
        endpoint: "0.0.0.0:4318"

processors:
  memory_limiter:
    check_interval: 1s
    limit_mib: 500
    spike_limit_mib: 100
  batch:
    send_batch_size: 5000
    send_batch_max_size: 5000

exporters:
  debug:
    verbosity: detailed
  otlp/mackerel:
    endpoint: otlp.mackerelio.com:4317
    headers:
      Mackerel-Api-Key: ${env:MACKEREL_APIKEY}
  otlphttp/mackerel:
    endpoint: https://otlp-vaxila.mackerelio.com
    headers:
      Accept: "*/*"
      Mackerel-Api-Key: ${env:MACKEREL_APIKEY}

service:
  pipelines:
    metrics:
      receivers:
        - otlp
      processors:
        - batch
      exporters:
        - debug
        - otlp/mackerel
    traces:
      receivers:
        - otlp
      processors:
        - batch
      exporters:
        - debug
        - otlphttp/mackerel

この設定ファイルを使って Docker で起動する。

> docker run --rm -v "${PWD}\otel-collector-config.yaml:/etc/otelcol/config.yaml" -p 4318:4318 otel/opentelemetry-collector:latest
ASP.NET Web API サーバーアプリケーション

Visual StudioASP.NET プロジェクトを作成。 Visual Studio 2022 にはテンプレートが含まれていなかったので、個別に追加コンポーネントのインストールが必要だった。

エクスポート先や計装したいものを選択して NuGet パッケージをインストールする。 上記の構成だと以下の通りでいいだろうか。

  • OpenTelemetry
  • OpenTelemetry.Exporter.OpenTelemetryProtocol (OTLPでのエクスポート)
  • OpenTelemetry.Exporter.Console (任意、デバッグ用途)
  • OpenTelemetry.Instrumentation.AspNetASP.NET サーバーの計装)
  • OpenTelemetry.Instrumentation.Runtime (.NET ランタイムの計装)
  • OpenTelemetry.Instrumentation.SqlClientSQL Server リクエストの計装)

.NET FrameworkAspNetCore ではなく AspNet の方らしい。

ではエントリポイントの Global.asax.cs に設定していく。 必要に応じた設定をすることになるが、今回は最小限とする。 カスタム計装したいものがある場合は ActivietySource などを DI に登録すると便利だろう。

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Security.Policy;
using System.Web;
using System.Web.Http;
using System.Web.Mvc;
using System.Web.Optimization;
using System.Web.Routing;
using Microsoft.Extensions.Options;

namespace OtelServer
{
    public class WebApiApplication : System.Web.HttpApplication
    {
        protected void Application_Start()
        {
            AreaRegistration.RegisterAllAreas();
            GlobalConfiguration.Configure(WebApiConfig.Register);
            FilterConfig.RegisterGlobalFilters(GlobalFilters.Filters);
            RouteConfig.RegisterRoutes(RouteTable.Routes);
            BundleConfig.RegisterBundles(BundleTable.Bundles);
        }
    }
}

↓↓↓↓↓

using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Security.Policy;
using System.Web;
using System.Web.Http;
using System.Web.Mvc;
using System.Web.Optimization;
using System.Web.Routing;
using Microsoft.Extensions.Options;
using OpenTelemetry;
using OpenTelemetry.Metrics;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;

namespace OtelServer
{
    public class WebApiApplication : System.Web.HttpApplication
    {
        private static string otlpEndpoint = "http://localhost:4318";

        private TracerProvider tracerProvider;
        private MeterProvider meterProvider;

        protected void Application_Start()
        {
            var resource = ResourceBuilder.CreateDefault()
                .AddService("OTelServer", "otel-dotnet-fx");

            tracerProvider = Sdk.CreateTracerProviderBuilder()
                .SetResourceBuilder(resource)
                .AddAspNetInstrumentation()
                .AddSqlClientInstrumentation(options => {
                    options.SetDbStatementForText = true; // SQL クエリも投稿する設定
                })
#if DEBUG
                .AddConsoleExporter()
#endif
                .AddOtlpExporter(options =>
                {
                    options.Endpoint = new Uri($"{otlpEndpoint}/v1/traces");
                })
                .Build();

            meterProvider = Sdk.CreateMeterProviderBuilder()
                .SetResourceBuilder(resource)
                .AddRuntimeInstrumentation()
                .AddAspNetInstrumentation()
                .AddSqlClientInstrumentation()
#if DEBUG
                .AddConsoleExporter()
#endif
                .AddOtlpExporter(options =>
                {
                    options.Endpoint = new Uri($"{otlpEndpoint}/v1/metrics");
                })
                .Build();

            AreaRegistration.RegisterAllAreas();
            GlobalConfiguration.Configure(WebApiConfig.Register);
            FilterConfig.RegisterGlobalFilters(GlobalFilters.Filters);
            RouteConfig.RegisterRoutes(RouteTable.Routes);
            BundleConfig.RegisterBundles(BundleTable.Bundles);
        }

        protected void Application_End()
        {
            tracerProvider?.Dispose();
            meterProvider?.Dispose();
        }
    }
}

また SQL Server データベースへのアクセスを見たいので、テンプレートについてきた ValuesController.cs で適当なクエリを投げることにする。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Web.Http;

namespace OtelServer.Controllers
{
    public class ValuesController : ApiController
    {
        // GET api/values
        public IEnumerable<string> Get()
        {
            return new[] { "value1", "value2" };
        }
    }
}

↓↓↓↓↓

using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Web.Http;
using Microsoft.Data.SqlClient;

namespace OtelServer.Controllers
{
    public class ValuesController : ApiController
    {
        // 本番では DI 推奨
        private static readonly string connectionString = @"Server=(localdb)\MSSQLLocalDB";

        // GET api/values
        public IEnumerable<string> Get()
        {
            var values = new List<string> { "value1", "value2" };
            
            // SQL Server に適当なクエリを送る  
            using (var connection = new SqlConnection(connectionString))
            {
                connection.Open();

                using (var command = new SqlCommand("SELECT GETDATE() AS CurrentTime;", connection))
                using (var reader = command.ExecuteReader())
                {
                    while (reader.Read())
                    {
                        values.Add(reader["CurrentTime"].ToString());
                    }
                }
            }

            return values;
        }
    }
}

実行して http://localhost:61266/api/values にアクセスするとレスポンスが返ってくれば OK。 ちなみにポートはアプリケーションごとに違うので、プロジェクトのプロパティを参照のこと。

そして Mackerel にトレースとメトリックが投稿されていれば OK(Collector の設定次第で時間差がある場合がある)。 OpenTelemetry semantic conventions に基づいているため、APM の HTTP サーバーとデータベースのタブにもそれぞれ専用のビューが現れる。 トレースには HTTP アクセスの子としてデータベースへのアクセスのスパンも現れる。

HTTP サーバー
データベース

Windows Forms クライアントアプリケーション

同様に Visual StudioWindows Forms プロジェクトを作成し、NuGet パッケージをインストールする。 デスクトップアプリは WinUI3 を使うようになって久しいので Win Form は懐かしみがある。

  • OpenTelemetry
  • OpenTelemetry.Exporter.Console
  • OpenTelemetry.Exporter.OpenTelemetryProtocol
  • OpenTelemetry.Instrumentation.Http (HTTP リクエストの計装)

エントリポイントの Program.cs にほぼ同じような計装を設定していく。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using System.Windows.Forms;

namespace OTelClient
{
    internal static class Program
    {
        /// <summary>
        /// アプリケーションのメイン エントリ ポイントです。
        /// </summary>
        [STAThread]
        static void Main()
        {
            Application.EnableVisualStyles();
            Application.SetCompatibleTextRenderingDefault(false);
            Application.Run(new Form1());
        }
    }
}

↓↓↓↓↓

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using System.Windows.Forms;
using OpenTelemetry;
using OpenTelemetry.Metrics;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;

namespace OTelClient
{
    internal static class Program
    {
        private static string otlpEndpoint = "http://localhost:4318";

        /// <summary>
        /// アプリケーションのメイン エントリ ポイントです。
        /// </summary>
        [STAThread]
        static void Main()
        {
            var resource = ResourceBuilder.CreateDefault()
                .AddService("OTelClient", "otel-dotnet-fx");

            using (
                var tracerProvider = Sdk.CreateTracerProviderBuilder()
                    .SetResourceBuilder(resource)
                    .AddHttpClientInstrumentation()
#if DEBUG
                    .AddConsoleExporter()
#endif
                    .AddOtlpExporter(options =>
                    {
                        options.Endpoint = new Uri($"{otlpEndpoint}/v1/traces");
                    })
                    .Build()
            )
            using (
                var meterProvider = Sdk.CreateMeterProviderBuilder()
                    .SetResourceBuilder(resource)
                    .AddHttpClientInstrumentation()
#if DEBUG
                    .AddConsoleExporter()
#endif
                    .AddOtlpExporter(options =>
                    {
                        options.Endpoint = new Uri($"{otlpEndpoint}/v1/metrics");
                    })
                    .Build()
            )
            {
                Application.EnableVisualStyles();
                Application.SetCompatibleTextRenderingDefault(false);
                Application.Run(new Form1());
            }
        }
    }
}

ASP.NET サーバーアプリケーションへのアクセスを見たいので、ボタンをクリックしたら HTTP リクエストすることにする。 フォームに適当にボタンを配置したらダブルクリックして生成したイベントハンドラを書き換える。

using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.Drawing;
using System.Linq;
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;
using System.Windows.Forms;

namespace OTelClient
{
    public partial class Form1 : Form
    {
        public Form1()
        {
            InitializeComponent();
        }
    }
}

↓↓↓↓↓

using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.Drawing;
using System.Linq;
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;
using System.Windows.Forms;

namespace OTelClient
{
    public partial class Form1 : Form
    {
        // 本番では DI 推奨
        private readonly HttpClient httpClient = new HttpClient();

        public Form1()
        {
            InitializeComponent();
        }

        private async void button1_Click(object sender, EventArgs e)
        {
            // サーバーアプリケーションに適当な HTTP リクエストを送る
            var res = await httpClient.GetAsync("http://localhost:61266/api/values");
            var content = await res.Content.ReadAsStringAsync();
            MessageBox.Show(content);
        }
    }
}

実行してボタンをにクリックするとメッセージボックスが表示されれば OK。

そして Mackerel にトレースとメトリックが投稿されていれば OK(Collector の設定次第で時間差がある場合がある)。 トレースにはクライアントアプリケーションでの HTTP リクエストだけでなく、その子としてサーバーアプリケーションでのスパンもデータベースアクセスを含めて現れる。

トレース

まとめ

特別な設定をすることなくお決まりのコードを書くだけで上から下まで繋がったトレースが見れるのがお手軽で便利! 今回は最低限の設定なので物足りないと感じる箇所もあるが、追加の設定やカスタム計装によってよりよくできそうな感触を得た。 また DI の有無で変わってくるけど、.NET Framework を含め、基本的にはどのタイプの .NET アプリケーションでも同じように設定できることがわかった。

AppRun から Mackerel にメトリックとトレースを送信する

さくらのクラウドの AppRun がベータトライアルできるということなので、同じくベータ利用が始まった Mackerel のトレーシング機能もあわせて試してみようという回。

www.sakura.ad.jp

mackerel.io

まずはいつも通り F# で ASP.NET の空アプリを作る。

dotnet new web -lang F#

OpenTelemetry 関係のパッケージを追加する。

dotnet add package OpenTelemetry.Exporter.Console --version 1.11.1
dotnet add package OpenTelemetry.Exporter.OpenTelemetryProtocol --version 1.11.1
dotnet add package OpenTelemetry.Extensions.Hosting --version 1.11.1
dotnet add package OpenTelemetry.Instrumentation.AspNetCore --version 1.11.0
dotnet add package OpenTelemetry.Instrumentation.Http --version 1.11.0

前回 Semantic KernelのOpenTelemetryメトリックをMackerelに送る - @yohfee.blog! でメトリックを計装したのと同様に今回はトレースも計装する。

open System
open Microsoft.AspNetCore.Builder
open Microsoft.Extensions.Hosting
open Microsoft.Extensions.DependencyInjection
open OpenTelemetry.Metrics
open OpenTelemetry.Resources
open OpenTelemetry.Trace

[<EntryPoint>]
let main args =
    let builder = WebApplication.CreateBuilder args

    let resource =
        ResourceBuilder.CreateDefault() |> _.AddService("HelloAppRun", "hello-app-run")

    let headers =
        Environment.GetEnvironmentVariable "MACKEREL_API_KEY"
        |> sprintf "Mackerel-Api-Key=%s"

    builder.Services
    |> _.AddOpenTelemetry()
    |> _.WithTracing(
        _.SetResourceBuilder(resource)
        >> _.AddAspNetCoreInstrumentation()
        >> _.AddHttpClientInstrumentation()
        >> _.AddConsoleExporter()
        >> _.AddOtlpExporter(fun options ->
            options.Endpoint <- Uri "https://otlp-vaxila.mackerelio.com/v1/traces"
            options.Protocol <- OpenTelemetry.Exporter.OtlpExportProtocol.HttpProtobuf
            options.Headers <- $"%s{headers},Accept=*/*")
        >> ignore
    )
    |> _.WithMetrics(
        _.SetResourceBuilder(resource)
        >> _.AddAspNetCoreInstrumentation()
        >> _.AddConsoleExporter()
        >> _.AddOtlpExporter(fun options ->
            options.Endpoint <- Uri "https://otlp.mackerelio.com:4317"
            options.Headers <- headers)
        >> ignore
    )
    |> ignore

    let app = builder.Build()

    app.MapGet("/", Func<string>(fun () -> "Hello World!")) |> ignore

    app.Run()

    0

トレースは collector を利用しない場合は次のように HTTP プロトコルでパスまで指定する必要があったり、ヘッダはカンマ区切りだったりと、最初ちょっとハマった。 その辺りのことは Mackerel のドキュメントでは Node.jsにOpenTelemetryを導入する | Mackerelトレーシング機能ガイド などに記載されている。 それはそれとして、ヘッダは SDK の方で気の利いたインターフェースにしてほしさがちょっとある。

fun options ->
  options.Endpoint <- Uri "https://otlp-vaxila.mackerelio.com/v1/traces"
  options.Protocol <- OpenTelemetry.Exporter.OtlpExportProtocol.HttpProtobuf
  options.Headers <- $"Mackerel-Api-Key={MACKEREL_API_KEY},Accept=*/*")

ここまでで、Mackerel の API キーを環境変数に設定すればローカルでもメトリックとトレースが送られて Mackerel 上で見ることができるはず。 何かおかしければ、前回 Semantic KernelのOpenTelemetryメトリックをMackerelに送る - @yohfee.blog! で紹介したように OTEL_DIAGNOSTICS.json を使うとヒントが得られるかも。 実際、先述の通りメトリックは送れてるけどトレースが送れてないのをこれで解決した。

さて、動作が確認できたのでコンテナイメージを作る。 今回は素朴にマルチステージビルドでいいだろう。

FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build
WORKDIR /source

COPY HelloAppRun.fsproj ./
RUN dotnet restore

COPY . .
WORKDIR /source
RUN dotnet publish -c release -o /app --no-restore

FROM mcr.microsoft.com/dotnet/aspnet:9.0
WORKDIR /app
COPY --from=build /app ./
ENTRYPOINT ["dotnet", "HelloAppRun.dll"]

あとは AppRun β版 | さくらのクラウド マニュアル の通り。 コンテナレジストリを作成して、先ほどのイメージをプッシュして、このイメージで AppRun アプリケーションを作成。

初回のデプロイがなかなか起動しなくて困った。 かといってログだとかが見れるようなところなども見当たらなくて途方に暮れていた。 まぁベータ版ということでまだまだこれからなんだろう。

では Mackerel に戻ってトレースを眺めてみる。 今回は実装が面白みのないものなので一覧や詳細も同様ではあるが。 集計ボタンをクリックしてみると下図のように、ローカルから送ったのも混ざってしまったが、 server.address に AppRun のホストが並んでいたり、service.instance.id を見ると3回デプロイしたことがわかったり。

トレーシングで何ができるの~ってのはこれからいろいろ試したいところだけど、早速わかった気になりたいかたはこちらをご覧ください。

mackerel.io

次はデータベースに接続してパフォーマンス計測してみよう。

mackerel.io