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行のデータになります。
そしてそれを Category
や State
などの Scopes
以外の情報と結合するべく素朴に cross join
などをしてしまった場合、元々1行のデータが5行のデータになってしまいます。
しかも、その5行のうち1行しか TraceId が含まれておらず、他の4行は null になります。
これでは何かと不便です。
欲しいのは TraceId
や HubMethod
, 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, ));
使い方は簡単で、jsonPayload
を flatten_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;