ねののお庭。

かりかりもふもふ。

【C#】Microsoft.Extensions.Logging のログを BigQuery で引きやすくする。

C# のデファクトの logger である Microsoft.Extensions.Logging で出力した構造化ログを BigQuery で引く時に一工夫するとなにかと楽ですよ、というお話。

課題

Microsoft.Extensions.Logging は以下のような構造化ログを出力します。(以下は TypedSignalR.Client.DevTools の examples から適当に出力してきました)

{
    "Timestamp": "2024-02-03T06:23:11.877Z",
    "EventId": 8201,
    "LogLevel": "Information",
    "Category": "AspNetCore.SignalR.Logging.HubLoggingFilter",
    "Message": "Invoking the SignalR hub method ChatHub.PostMessage",
    "State": {
        "Message": "Invoking the SignalR hub method ChatHub.PostMessage",
        "Hub": "ChatHub",
        "HubMethod": "PostMessage",
        "{OriginalFormat}": "Invoking the SignalR hub method {Hub}.{HubMethod}"
    },
    "Scopes": [
        {
            "Message": "SpanId:80fcecd7d840f12b, TraceId:86e245da4a456dae4d14424f68b8147b, ParentId:0000000000000000",
            "SpanId": "80fcecd7d840f12b",
            "TraceId": "86e245da4a456dae4d14424f68b8147b",
            "ParentId": "0000000000000000"
        },
        {
            "Message": "ConnectionId:0HN14G1UV1JVO",
            "ConnectionId": "0HN14G1UV1JVO"
        },
        {
            "Message": "RequestPath:/hubs/ChatHub RequestId:0HN14G1UV1JVO:0000003D",
            "RequestId": "0HN14G1UV1JVO:0000003D",
            "RequestPath": "/hubs/ChatHub"
        },
        {
            "Message": "TransportConnectionId:_D47xNKl5irlAMu36H60ew",
            "TransportConnectionId": "_D47xNKl5irlAMu36H60ew"
        },
        {
            "Message": "Hub:ChatHub, HubMethod:PostMessage, HubInvocationId:a173b41e-310b-4326-b02a-3d8fbc00eb27",
            "Hub": "ChatHub",
            "HubMethod": "PostMessage",
            "HubInvocationId": "a173b41e-310b-4326-b02a-3d8fbc00eb27",
            "{OriginalFormat}": "Hub:{Hub}, HubMethod:{HubMethod}, HubInvocationId:{HubInvocationId}"
        }
    ]
}

このような構造化ログが BigQuery に流しているとしましょう。 そして BigQuery に突っ込んでいるのだから、当然これらをよしなに引いて色々したい、と思うわけですが、地味に困る事があります。 それは Scopes の取り扱いです。

BigQuery では以下のように jsonPayload (= 構造化ログが保存されているプロパティ) のネストしたプロパティに直接アクセスできません。Cloud Logging だと直接アクセスできるんですけどねー...。

⭕️ jsonPayload.Category = "xxx.yyy.zzz"
❌ jsonPayload.Scopes.TraceId="xxxxxxxxxxxxxxxxxxxx"

Scopes には多くのコンテキスト情報が含まれています。 なので SQL で色々捏ねるのに便利なのですが、Scopes はオブジェクトの配列のため、unnest() をしないと Scopes に含まれるデータにアクセスできません。 しかし unnest() は配列を行単位に分解していしまうので、ぶっちゃけ不便です。 何がどう不便かというと、例えば上にのせているログの Scopes の配列の要素は5つですから、unnest() して場合5行のデータになります。 そしてそれを CategoryState などの Scopes 以外の情報と結合するべく素朴に cross join などをしてしまった場合、元々1行のデータが5行のデータになってしまいます。 しかも、その5行のうち1行しか TraceId が含まれておらず、他の4行は null になります。

これでは何かと不便です。 欲しいのは TraceIdHubMethod, HubInvocationId といった Scopes に入っている情報にサクッとアクセスする事が可能になっている1ログあたり1行のデータです。

解決策

要するに実現したい事は、Scopes をフラットにして Category, EventId, State などと結合する事です。 そしてこの処理は BigQuery でいろいろする上で頻繁に使う事になるので、ユーザー定義関数(UDF) として定義すると良いでしょう。

結論としては、以下のような UDF を書けば望んだ結果が得られます。 なお、以下では create temp function で一時的な UDF として作成してますが、create or replace function で永続的な UDF を作成してしまった方が実運用上は便利かと思います。

create temp function flatten_scopes(json_payload any type)
as((
    select as struct 
        json_payload.category as category,
        cast(json_payload.eventid as INT64) as eventid,
        json_payload.loglevel as loglevel,
        json_payload.timestamp as timestamp,
        (select parentid from unnest(json_payload.scopes) where parentid is not null) as parentid,
        (select traceid from unnest(json_payload.scopes) where traceid is not null) as traceid,
        (select spanid from unnest(json_payload.scopes) where spanid is not null) as spanid,
        (select connectionid from unnest(json_payload.scopes) where connectionid is not null) as connectionid,
        (select requestpath from unnest(json_payload.scopes) where requestpath is not null) as requestpath,
        (select requestid from unnest(json_payload.scopes) where requestid is not null) as requestid,
        (select transportconnectionid from unnest(json_payload.scopes) where transportconnectionid is not null) as transportconnectionid,
        (select hub from unnest(json_payload.scopes) where hub is not null) as hub,
        (select hubmethod from unnest(json_payload.scopes) where hubmethod is not null) as hubmethod,
        (select hubinvocationid from unnest(json_payload.scopes) where hubinvocationid is not null) as hubinvocationid,
        (select actionname from unnest(json_payload.scopes) where actionname is not null) as actionname,
        (select actionid from unnest(json_payload.scopes) where actionid is not null) as actionid,
        json_payload.message as message,
        json_payload.state as state,
));

使い方は簡単で、jsonPayloadflatten_scopes の引数に渡してあげればいいだけです。flatten_scopes(...).* のように、最後に .* を付けると良い何かと良いでしょう。

with one_week_table as (
  select *
  from `xxxxxx`
  where date(timestamp) between date_sub(current_date(), interval 7 day) and current_date()
  limit 1
)
select flatten_scopes(one_week_table.jsonPayload).* from one_week_table;

関連記事