0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

PR: データブリックス・ジャパン株式会社
MLflowで実践するLLMOps

Unity Gateway の統合トレーステーブルを試す: 計装なしでプロンプトと応答まで記録される

0
Last updated at Posted at 2026-10-16

はじめに

Unity Gateway に、統合トレーステーブルという機能がベータとして追加されています。Unity Gateway を通ったモデルや MCP サービスへのリクエストが、OpenTelemetry 形式のスパンとして1つの Unity Catalog テーブルに記録される機能です。

呼び出し側で MLflow や OpenTelemetry の計装をしなくても、ゲートウェイを通っただけで記録される。これが本当なら、組織の中で誰がどのモデルに何を投げているかを、1つのテーブルに SQL を書くだけで追えることになります。

実際にノートブックから Unity Gateway 経由でモデルを呼び、トレーステーブルに何が入るかを確かめました。

統合トレーステーブルとは

メタストアに1つのテーブル

トレーステーブルは、メタストア管理者が一度だけセットアップします。Unity Gateway のガバナンス画面にあるトレースタブで、テーブルを置くカタログとスキーマを選ぶだけです。前提として、アカウントコンソールのプレビューページで Enhanced Unity Gateway を有効にしておく必要があります。

Screenshot 2026-10-17 at 7.17.30.JPG

セットアップ後は、そのメタストアにアタッチされたすべてのワークスペースの 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件が表示されます。

Screenshot 2026-10-16 at 17.54.57.png

一覧にはサービス名とプリンシパルが表示されます。行を開くと、入力と出力の本文やトークン数も確認できます。

Screenshot 2026-10-16 at 17.53.19.png

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本につなぐ機能もベータで用意されています。こちらはワークスペースごとの有効化が必要で、今回は試していません。

参考リンク

はじめてのDatabricks

はじめてのDatabricks

Databricks無料トライアル

Databricks無料トライアル

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?