はじめに
Unity Gateway に、統合トレーステーブルという機能がベータとして追加されています。Unity Gateway を通ったモデルや MCP サービスへのリクエストが、OpenTelemetry 形式のスパンとして1つの Unity Catalog テーブルに記録される機能です。
呼び出し側で MLflow や OpenTelemetry の計装をしなくても、ゲートウェイを通っただけで記録される。これが本当なら、組織の中で誰がどのモデルに何を投げているかを、1つのテーブルに SQL を書くだけで追えることになります。
実際にノートブックから Unity Gateway 経由でモデルを呼び、トレーステーブルに何が入るかを確かめました。
統合トレーステーブルとは
メタストアに1つのテーブル
トレーステーブルは、メタストア管理者が一度だけセットアップします。Unity Gateway のガバナンス画面にあるトレースタブで、テーブルを置くカタログとスキーマを選ぶだけです。前提として、アカウントコンソールのプレビューページで Enhanced Unity Gateway を有効にしておく必要があります。
セットアップ後は、そのメタストアにアタッチされたすべてのワークスペースの Unity Gateway のトラフィックが、このテーブルに記録されます。エンドポイントごとに有効化する推論テーブルとの一番の違いはここです。
| 統合トレーステーブル | 推論テーブル | |
|---|---|---|
| 対象 | Unity Gateway のモデルサービスと MCP サービス | モデルサービングエンドポイントごと |
| セットアップ | メタストア管理者が一度だけ | エンドポイントごとに有効化 |
| 形式 | OpenTelemetry のスパン | Databricks 独自のスキーマ |
1リージョンで運用している組織ならメタストアは1つなので、実質的にはアカウント全体で1つのテーブルに集まることになります。サービスごとに記録を外す設定はありません。ログを無効にしたサービスが抜け穴にならない、という点がこの機能の狙いなので、当然といえば当然です。
既定では作成者しか読めない
既定では、テーブルを作成したメタストア管理者しかクエリできません。後で見るように、テーブルにはプロンプトと応答の本文がそのまま入ります。全員に SELECT を付けると、他の人やチームのプロンプトまで読めてしまいます。
ドキュメントでは、ABAC の行フィルターで「サービスのオーナーは自分のサービスの行だけ見える」形にしてから権限を付ける方法が紹介されています。1つのテーブルに集約する設計なので、アクセス制御も1か所で済む。運用する立場からすると、ここが効いてきます。
Unity Gateway 経由でモデルを呼ぶ
ノートブックから OpenAI 互換 API で呼びます。MLflow や OpenTelemetry の計装は一切していません。
%pip install -q --upgrade openai databricks-sdk
dbutils.library.restartPython()
認証はノートブックのコンテキストから取ります。プロンプトの先頭には、自分の呼び出しを見分けるための文字列を入れておきます。
import uuid
from databricks.sdk import WorkspaceClient
from openai import OpenAI
TRACE_TABLE = "<catalog>.<schema>.unity_gateway_otel_spans"
MODEL_SERVICE = "system.ai.claude-opus-5-5"
w = WorkspaceClient()
token = w.config.authenticate()["Authorization"].split(" ", 1)[1]
client = OpenAI(api_key=token, base_url=f"{w.config.host.rstrip('/')}/ai-gateway/mlflow/v1")
# SQLで自分の呼び出しを見分けるための文字列
MARKER = f"trace-nb-{uuid.uuid4().hex[:8]}"
# 以降のクエリはこの時刻より後に絞る。タイムゾーンの影響を受けないよう UNIX 秒で持つ
START_EPOCH = spark.sql("SELECT unix_timestamp() - 60 AS t").first()["t"]
def answer_text(content):
# 推論するモデルでは content が文字列ではなくブロックのリストで返る
if isinstance(content, str):
return content
return "".join(b.get("text", "") for b in content if b.get("type") == "text")
questions = [
"Unity Catalog とは何か、一文で答えてください。",
"Delta Lake とは何か、一文で答えてください。",
"OpenTelemetry とは何か、一文で答えてください。",
]
for q in questions:
resp = client.chat.completions.create(
model=MODEL_SERVICE,
max_tokens=500,
messages=[{"role": "user", "content": f"{MARKER} {q}"}],
)
print(answer_text(resp.choices[0].message.content)[:80])
3件とも応答が返ってきます。
トレーステーブルを見る
トレースタブで見る
Unity Gateway のガバナンス画面にあるトレースタブを開き、フィルターのプリンシパルで自分を選ぶと、いま呼んだ3件が表示されます。
一覧にはサービス名とプリンシパルが表示されます。行を開くと、入力と出力の本文やトークン数も確認できます。
SQL で見る
同じ内容を SQL で取り出します。テーブルは time 列でクラスタリングされているので、時刻の条件は必ず入れます。
display(spark.sql(f"""
SELECT
time,
name,
service_name,
(end_time_unix_nano - start_time_unix_nano) / 1e6 AS duration_ms,
-- 属性に識別用の文字列が含まれるか。自分の呼び出しなら true
to_json(attributes) LIKE '%{MARKER}%' AS mine,
trace_id
FROM {TRACE_TABLE}
WHERE time >= timestamp_seconds({START_EPOCH})
AND service_name = '{MODEL_SERVICE}'
-- ゲートウェイが受けた1リクエストに1行。parent_span_id IS NULL では取れない(後述)
AND kind = 'SPAN_KIND_SERVER'
ORDER BY time
"""))
| time | name | service_name | duration_ms | mine |
|---|---|---|---|---|
| 08:47:46.780 | ai-gateway-mlflow/v1/chat/completions | system.ai.claude-opus-5-5 | 3956.0 | true |
| 08:47:50.777 | ai-gateway-mlflow/v1/chat/completions | system.ai.claude-opus-5-5 | 3258.0 | true |
| 08:47:54.057 | ai-gateway-mlflow/v1/chat/completions | system.ai.claude-opus-5-5 | 2856.0 | true |
スパン名には、呼び出しに使った API の種類が入ります。Responses API で呼べば ai-gateway-openai/v1/responses、Anthropic の Messages API なら ai-gateway-anthropic/v1/messages です。
1リクエストは2つのスパンになる
ここが最初のハマりどころです。
ドキュメントのクエリ例では、1リクエストに1行を取り出すのに parent_span_id IS NULL を条件にしています。この条件で手元のテーブルに投げたところ、自分の分も他の人の分も含めて1件も返ってきませんでした。
1つのリクエストに属するスパンを並べると理由が分かります。
display(spark.sql(f"""
WITH one AS (
SELECT trace_id FROM {TRACE_TABLE}
WHERE time >= timestamp_seconds({START_EPOCH})
AND service_name = '{MODEL_SERVICE}'
AND kind = 'SPAN_KIND_SERVER'
ORDER BY time DESC LIMIT 1
)
SELECT t.name, t.kind, t.span_id, t.parent_span_id,
(t.end_time_unix_nano - t.start_time_unix_nano) / 1e6 AS duration_ms
FROM {TRACE_TABLE} t JOIN one USING (trace_id)
WHERE t.time >= timestamp_seconds({START_EPOCH})
ORDER BY t.start_time_unix_nano
"""))
| name | kind | span_id | parent_span_id | duration_ms |
|---|---|---|---|---|
| ai-gateway-mlflow/v1/chat/completions | SPAN_KIND_SERVER | e876837d2f84417c | 5b1055fa61a6a894 | 2856.0 |
| system.ai.databricks-claude-opus-5-5 | SPAN_KIND_CLIENT | bfb5f63b12274fa8 | e876837d2f84417c | 2734.0 |
1リクエストは2段のスパンになっています。
-
SPAN_KIND_SERVER: ゲートウェイがリクエストを受けたスパン -
SPAN_KIND_CLIENT: ゲートウェイからモデルを呼んだスパン。親は SERVER スパン
SERVER スパンの parent_span_id にも値が入っていて、その親はテーブルの中にありません。そのため parent_span_id IS NULL では取れず、今回は kind = 'SPAN_KIND_SERVER' で1リクエスト1行を取り出しました。
CLIENT スパンの名前が system.ai.databricks-claude-opus-5-5 になっている点も目を引きます。モデルサービス system.ai.claude-opus-5-5 の先で、Foundation Model API のエンドポイントが呼ばれていることが読み取れます。
属性にはプロンプトと応答がそのまま入る
SERVER スパンの attributes を、そのまま JSON で表示します。
display(spark.sql(f"""
SELECT name, to_json(attributes) AS attributes_json
FROM {TRACE_TABLE}
WHERE time >= timestamp_seconds({START_EPOCH})
AND service_name = '{MODEL_SERVICE}'
AND kind = 'SPAN_KIND_SERVER'
ORDER BY time DESC LIMIT 1
"""))
見やすいように整形しています。ワークスペースの URL とユーザー名は伏せました。
{
"databricks.api_type": "mlflow/v1/chat/completions",
"databricks.endpoint_name": "system.ai.claude-opus-5-5",
"databricks.latency_ms": 2855,
"databricks.request_id": "d158e55e-8138-4983-95b5-9568e6c5fefe",
"databricks.requester_type": "USER",
"databricks.time_to_first_byte_ms": 2850,
"databricks.uc_service_type": "model_service",
"databricks.url": "https://<workspace-url>/ai-gateway/mlflow/v1/chat/completions",
"enduser.id": "<ユーザー名>@databricks.com",
"gen_ai.input.messages": "[{\"role\":\"user\",\"parts\":[{\"type\":\"text\",\"content\":\"trace-nb-bd5ab847 OpenTelemetry とは何か、一文で答えてください。\"}]}]",
"gen_ai.output.messages": "[{\"role\":\"assistant\",\"parts\":[{\"type\":\"text\",\"content\":\"OpenTelemetry とは、アプリケーションやインフラからトレース・メトリクス・ログといったテレメトリデータを収集・生成・エクスポートするための、ベンダー中立なオープンソースの標準仕様、API、SDK、ツール群です。\"}]}]",
"gen_ai.usage.input_tokens": 42,
"gen_ai.usage.output_tokens": 170,
"http.response.status_code": 200,
"mlflow.chat.tokenUsage": "{\"input_tokens\":42,\"output_tokens\":170,\"total_tokens\":212,\"cache_read_input_tokens\":0,\"cache_creation_input_tokens\":0}",
"mlflow.spanType": "LLM",
"mlflow.tokenCount": 212,
"request_preview": "trace-nb-bd5ab847 OpenTelemetry とは何か、一文で答えてください。",
"response_preview": "OpenTelemetry とは、アプリケーションやインフラからトレース・メトリクス・ログといったテレメトリデータを収集・生成・エクスポートするための、ベンダー中立なオープンソースの標準仕様、API、SDK、ツール群です。"
}
呼び出し側は何も計装していないのに、次の情報がそろっています。
- 誰が呼んだか (
enduser.id、databricks.requester_type) - どのモデルサービスを、どの API で呼んだか (
databricks.endpoint_name、databricks.api_type) - プロンプトと応答の本文 (
gen_ai.input.messages、gen_ai.output.messages) - トークン数とレイテンシ
gen_ai.* は OpenTelemetry の GenAI セマンティック規約、mlflow.* は MLflow のトレースが使うキーです。両方が入っているので、MLflow のツールからも、OpenTelemetry に対応した他のツールからも読めるようになっています。
本文がそのまま入っているので、前半で触れたアクセス制御は実際に重要になります。テーブルを公開する前に、行フィルターか、PII を伏せたテーブルを用意するところまでセットで考えておく必要があります。
属性の値を SQL で取り出すとき
もう1つのハマりどころです。
attributes は VARIANT 型で、キーにドットを含みます。ドキュメントのクエリ例では、attributes:`enduser.id`::string のようにバッククォートで囲んで値を取り出しています。今回この書き方で enduser.id や gen_ai.usage.input_tokens を取り出したところ、どの行でも null が返ってきました。上の JSON のとおり、値自体は入っています。
最初はこの null を見て「要求者が記録されていない」と思い込み、別の方法で自分の行を探そうとして時間を使いました。値が null のときは、まず to_json(attributes) で中身を確認してください。
まとめ
Unity Gateway の統合トレーステーブルを、ノートブックからの呼び出しで試して分かったことをまとめます。
- トレーステーブルはメタストアに1つ。メタストア管理者が一度セットアップすると、すべてのワークスペースの Unity Gateway のトラフィックが記録される
- 呼び出し側の計装は不要。ゲートウェイを通るだけで、要求者、モデル、プロンプトと応答の本文、トークン数が記録される
- 1リクエストは SERVER と CLIENT の2段のスパンになる。SERVER スパンにも
parent_span_idが入っているので、parent_span_id IS NULLでは取れない。kind = 'SPAN_KIND_SERVER'で絞る - 今回の環境では、
attributes:`enduser.id`の形で値を取り出すとnullになった。中身はto_json(attributes)で確認できる - 本文がそのまま入るので、既定では作成者しか読めない。共有する前に ABAC の行フィルターを用意する
一番の収穫は、呼び出し側で何もしなくても、プロンプトと応答の本文まで含めて1つのテーブルに残ると確かめられたことでした。一方で、ドキュメントのクエリ例をそのまま流すと、ルートスパンの条件でも属性の取り出しでも結果が空になり、記録されていないように見えます。新しいテーブルを相手にするときは、クエリ例を流す前に、まず数行を to_json で眺めて実際の形を確かめる。この順番を飛ばさない方が良さそうです。
なお、Claude Code や Codex などのクライアント側から送るスパンを同じテーブルに入れる機能や、アプリのトレースとゲートウェイのスパンを1本につなぐ機能もベータで用意されています。こちらはワークスペースごとの有効化が必要で、今回は試していません。



