Skip to content

Commit 0ba5d9f

Browse files
eknollclaudeDevonFulcher
authored
feat: populate dbt_cloud_account_identifier in telemetry events (#701)
## Summary Populate the `dbt_cloud_account_identifier` field in telemetry events, which was previously hardcoded to an empty string. ## What Changed - Added `account_identifier` field to `DbtMcpSettings` - Added `get_account()` method to `DbtAdminAPIClient` (`GET /api/v2/accounts/{account_id}/`) - Resolve account identifier during credential setup: - **OAuth path**: Extract from JWT claim `https://dbt.com/account_identifier`, falling back to Admin API - **PAT path**: Fetch from Admin API - Both paths degrade gracefully — if resolution fails, the field remains empty - Use the resolved value in the `VortexTelemetryDbtCloudContext` telemetry event ## Why We populate the `account_id` field rather than hardcoding an empty string to conform to telemetry collector expectations. ## Related Issues N/A ## Checklist - [x] I have performed a self-review of my code - [x] I have made corresponding changes to the documentation (in https://github.com/dbt-labs/docs.getdbt.com) if required -- No documentation changes needed; this is an internal telemetry change - [x] I have added tests that prove my fix is effective or that my feature works - [x] New and existing unit tests pass locally with my changes ## Additional Notes **Testing evidence:** - `task test:unit`: 469 tests passed - `ruff check`, `ruff format`, `mypy`: all clean Tests added: - `test_get_account` — verifies the new Admin API client method - `test_emit_tool_called_event_account_identifier_none` — verifies empty string fallback when identifier is unresolved - `test_oauth_extracts_identifier_from_jwt_claims` — verifies JWT claim extraction - `test_oauth_falls_back_to_admin_api` — verifies API fallback when JWT claim is absent - `test_pat_fetches_identifier_from_admin_api` — verifies PAT path resolution - `test_api_failure_does_not_break_credentials` — verifies graceful degradation --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: Devon Fulcher <24593113+DevonFulcher@users.noreply.github.com>
1 parent 0f08aa5 commit 0ba5d9f

8 files changed

Lines changed: 203 additions & 4 deletions

File tree

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
kind: Enhancement or New Feature
2+
body: Populate dbt_cloud_account_identifier in telemetry events
3+
time: 2026-04-02T12:00:00.000000-07:00

src/dbt_mcp/config/config_providers/admin_api.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
1+
from __future__ import annotations
2+
3+
from typing import TYPE_CHECKING
4+
15
from dbt_mcp.config.headers import AdminApiHeadersProvider
2-
from dbt_mcp.config.credentials import CredentialsProvider
36

47
from .base import AdminApiConfig, ConfigProvider
58

9+
if TYPE_CHECKING:
10+
from dbt_mcp.config.credentials import CredentialsProvider
11+
612

713
class DefaultAdminApiConfigProvider(ConfigProvider[AdminApiConfig]):
814
def __init__(self, credentials_provider: CredentialsProvider):

src/dbt_mcp/config/credentials.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@
66

77
from filelock import FileLock
88

9+
from dbt_mcp.config.config_providers.admin_api import DefaultAdminApiConfigProvider
910
from dbt_mcp.config.headers import TokenProvider
1011
from dbt_mcp.config.settings import DbtMcpSettings
12+
from dbt_mcp.dbt_admin.client import DbtAdminAPIClient
1113
from dbt_mcp.oauth.context_manager import DbtPlatformContextManager
1214
from dbt_mcp.oauth.dbt_platform import DbtPlatformContext
1315
from dbt_mcp.oauth.expiry import STARTUP_EXPIRY_BUFFER_SECONDS
@@ -177,6 +179,21 @@ def __init__(self, settings: "DbtMcpSettings") -> None:
177179
self.settings = settings
178180
self.token_provider: TokenProvider | None = None
179181
self.authentication_method: AuthenticationMethod | None = None
182+
self.account_identifier: str | None = None
183+
184+
async def _resolve_account_identifier(self) -> None:
185+
"""Fetch and store the account identifier from the Admin API.
186+
187+
Fails silently — account_identifier remains None on error.
188+
"""
189+
if not self.settings.dbt_account_id or not self.settings.actual_host:
190+
return
191+
try:
192+
admin_client = DbtAdminAPIClient(DefaultAdminApiConfigProvider(self))
193+
account_data = await admin_client.get_account(self.settings.dbt_account_id)
194+
self.account_identifier = account_data.get("identifier")
195+
except Exception as e:
196+
logger.warning(f"Failed to fetch account identifier: {e}")
180197

181198
def _log_settings(self) -> None:
182199
settings = self.settings.model_dump()
@@ -245,6 +262,7 @@ async def get_credentials(self) -> "tuple[DbtMcpSettings, TokenProvider]":
245262
context_manager=dbt_platform_context_manager,
246263
)
247264
self.token_provider = token_provider
265+
await self._resolve_account_identifier()
248266

249267
# Only validate CLI settings here — platform settings were already
250268
# checked at the top of get_credentials() and the OAuth flow has
@@ -280,6 +298,7 @@ async def get_credentials(self) -> "tuple[DbtMcpSettings, TokenProvider]":
280298
self.settings.host_prefix = fetched_prefix
281299
self.settings.dbt_host = self.settings.base_host
282300
logger.info(f"Fetched prefix {fetched_prefix} from dbt Platform.")
301+
await self._resolve_account_identifier()
283302
validate_settings(self.settings)
284303
self.authentication_method = AuthenticationMethod.ENV_VAR
285304
self._log_settings()

src/dbt_mcp/dbt_admin/client.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,14 @@ async def _make_request(
5252
logger.error(f"API request failed: {e}")
5353
raise AdminAPIError(f"API request failed: {e}")
5454

55+
async def get_account(self, account_id: int) -> dict[str, Any]:
56+
"""Get details for an account."""
57+
result = await self._make_request(
58+
"GET",
59+
f"/api/v2/accounts/{account_id}/",
60+
)
61+
return result.get("data", {})
62+
5563
@staticmethod
5664
def resolve_environments(
5765
environments: list[DbtPlatformEnvironmentResponse],

src/dbt_mcp/tracking/tracking.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,8 @@ async def emit_tool_called_event(
145145
session_id=str(self.session_id),
146146
referrer_url="",
147147
dbt_cloud_account_id=dbt_cloud_account_id,
148-
dbt_cloud_account_identifier="",
148+
dbt_cloud_account_identifier=self.credentials_provider.account_identifier
149+
or "",
149150
dbt_cloud_project_id="",
150151
dbt_cloud_environment_id="",
151152
dbt_cloud_user_id=dbt_cloud_user_id,

tests/unit/dbt_admin/test_client.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -636,6 +636,28 @@ async def test_list_projects(client):
636636
)
637637

638638

639+
async def test_get_account(client):
640+
mock_response = MagicMock()
641+
mock_response.json.return_value = {
642+
"data": {"id": 12345, "name": "Test Account", "identifier": "ab123"}
643+
}
644+
mock_response.raise_for_status.return_value = None
645+
646+
mock_client = create_mock_httpx_client(mock_response)
647+
648+
with patch("httpx.AsyncClient", return_value=mock_client):
649+
result = await client.get_account(12345)
650+
651+
assert result == {"id": 12345, "name": "Test Account", "identifier": "ab123"}
652+
headers = await client.get_headers()
653+
mock_client.request.assert_called_once_with(
654+
"GET",
655+
"https://cloud.getdbt.com/api/v2/accounts/12345/",
656+
headers=headers,
657+
follow_redirects=True,
658+
)
659+
660+
639661
async def test_list_projects_no_semantic_layer(client):
640662
mock_response = MagicMock()
641663
mock_response.json.return_value = {

tests/unit/oauth/test_credentials_provider.py

Lines changed: 107 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ async def test_authentication_method_oauth(self):
3131
mock_dbt_context.prod_environment.id = 123
3232
mock_decoded_token = MagicMock()
3333
mock_decoded_token.access_token_response.access_token = "mock_token"
34+
mock_decoded_token.decoded_claims = {}
3435
mock_dbt_context.decoded_access_token = mock_decoded_token
3536

3637
with (
@@ -134,6 +135,7 @@ async def test_oauth_path_does_not_set_dbt_token(self):
134135
mock_dbt_context.prod_environment.id = 123
135136
mock_decoded_token = MagicMock()
136137
mock_decoded_token.access_token_response.access_token = "mock_oauth_token"
138+
mock_decoded_token.decoded_claims = {}
137139
mock_dbt_context.decoded_access_token = mock_decoded_token
138140

139141
with (
@@ -171,6 +173,7 @@ async def test_oauth_path_uses_factory_with_background_refresh(self):
171173
mock_dbt_context.prod_environment.id = 123
172174
mock_decoded_token = MagicMock()
173175
mock_decoded_token.access_token_response.access_token = "mock_token"
176+
mock_decoded_token.decoded_claims = {}
174177
mock_dbt_context.decoded_access_token = mock_decoded_token
175178

176179
with (
@@ -212,6 +215,7 @@ async def test_oauth_path_does_not_call_validate_settings(self):
212215
mock_dbt_context.prod_environment.id = 123
213216
mock_decoded_token = MagicMock()
214217
mock_decoded_token.access_token_response.access_token = "mock_token"
218+
mock_decoded_token.decoded_claims = {}
215219
mock_dbt_context.decoded_access_token = mock_decoded_token
216220

217221
with (
@@ -259,7 +263,9 @@ async def test_dbt_platform_url_always_includes_prefix_once(self, dbt_host: str)
259263
mock_dbt_context.user_id = 789
260264
mock_dbt_context.dev_environment = None
261265
mock_dbt_context.prod_environment.id = 123
262-
mock_dbt_context.decoded_access_token = MagicMock()
266+
mock_decoded_token = MagicMock()
267+
mock_decoded_token.decoded_claims = {}
268+
mock_dbt_context.decoded_access_token = mock_decoded_token
263269

264270
captured_urls: list[str] = []
265271

@@ -313,7 +319,9 @@ async def test_warning_logged_when_dbt_token_set_but_platform_settings_incomplet
313319
mock_dbt_context.user_id = 789
314320
mock_dbt_context.dev_environment = None
315321
mock_dbt_context.prod_environment.id = 123
316-
mock_dbt_context.decoded_access_token = MagicMock()
322+
mock_decoded_token = MagicMock()
323+
mock_decoded_token.decoded_claims = {}
324+
mock_dbt_context.decoded_access_token = mock_decoded_token
317325

318326
with (
319327
patch(
@@ -336,3 +344,100 @@ async def test_warning_logged_when_dbt_token_set_but_platform_settings_incomplet
336344

337345
assert "DBT_TOKEN is set but will be ignored" in caplog.text
338346
assert "Falling back to OAuth authentication" in caplog.text
347+
348+
349+
class TestCredentialsProviderAccountIdentifier:
350+
"""Test account_identifier population in both OAuth and PAT paths."""
351+
352+
@pytest.mark.asyncio
353+
async def test_oauth_fetches_identifier_from_admin_api(self):
354+
"""OAuth path fetches account_identifier from Admin API."""
355+
mock_settings = DbtMcpSettings.model_construct(
356+
dbt_host="cloud.getdbt.com",
357+
dbt_prod_env_id=123,
358+
dbt_account_id=456,
359+
dbt_token=None,
360+
)
361+
362+
credentials_provider = CredentialsProvider(mock_settings)
363+
364+
mock_dbt_context = MagicMock()
365+
mock_dbt_context.account_id = 456
366+
mock_dbt_context.host_prefix = "ab123"
367+
mock_dbt_context.user_id = 789
368+
mock_dbt_context.dev_environment.id = 111
369+
mock_dbt_context.prod_environment.id = 123
370+
mock_decoded_token = MagicMock()
371+
mock_decoded_token.access_token_response.access_token = "mock_token"
372+
mock_decoded_token.decoded_claims = {}
373+
mock_dbt_context.decoded_access_token = mock_decoded_token
374+
375+
with (
376+
patch(
377+
"dbt_mcp.config.credentials.get_dbt_platform_context",
378+
return_value=mock_dbt_context,
379+
),
380+
patch(
381+
"dbt_mcp.config.credentials.OAuthTokenProvider"
382+
) as mock_token_provider,
383+
patch("dbt_mcp.config.settings.validate_dbt_cli_settings", return_value=[]),
384+
patch(
385+
"dbt_mcp.dbt_admin.client.DbtAdminAPIClient.get_account",
386+
new_callable=AsyncMock,
387+
return_value={"id": 456, "identifier": "ab123"},
388+
),
389+
):
390+
mock_token_provider.create = AsyncMock(return_value=MagicMock())
391+
392+
await credentials_provider.get_credentials()
393+
394+
assert credentials_provider.account_identifier == "ab123"
395+
396+
@pytest.mark.asyncio
397+
async def test_pat_fetches_identifier_from_admin_api(self):
398+
"""PAT path fetches account_identifier from Admin API."""
399+
mock_settings = DbtMcpSettings.model_construct(
400+
dbt_host="cloud.getdbt.com",
401+
dbt_prod_env_id=123,
402+
dbt_account_id=456,
403+
dbt_token="test_token",
404+
)
405+
406+
credentials_provider = CredentialsProvider(mock_settings)
407+
408+
with (
409+
patch("dbt_mcp.config.settings.validate_settings"),
410+
patch(
411+
"dbt_mcp.dbt_admin.client.DbtAdminAPIClient.get_account",
412+
new_callable=AsyncMock,
413+
return_value={"id": 456, "identifier": "ab123"},
414+
),
415+
):
416+
await credentials_provider.get_credentials()
417+
418+
assert credentials_provider.account_identifier == "ab123"
419+
420+
@pytest.mark.asyncio
421+
async def test_api_failure_does_not_break_credentials(self):
422+
"""If Admin API call fails, get_credentials still succeeds."""
423+
mock_settings = DbtMcpSettings.model_construct(
424+
dbt_host="cloud.getdbt.com",
425+
dbt_prod_env_id=123,
426+
dbt_account_id=456,
427+
dbt_token="test_token",
428+
)
429+
430+
credentials_provider = CredentialsProvider(mock_settings)
431+
432+
with (
433+
patch("dbt_mcp.config.settings.validate_settings"),
434+
patch(
435+
"dbt_mcp.dbt_admin.client.DbtAdminAPIClient.get_account",
436+
new_callable=AsyncMock,
437+
side_effect=Exception("API error"),
438+
),
439+
):
440+
_, token_provider = await credentials_provider.get_credentials()
441+
442+
assert credentials_provider.account_identifier is None
443+
assert token_provider is not None

tests/unit/tracking/test_tracking.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ async def test_emit_tool_called_event_enabled(self):
5555
)
5656

5757
mock_credentials_provider = MockCredentialsProvider(mock_settings)
58+
mock_credentials_provider.account_identifier = "ab123"
5859

5960
tracker = DefaultUsageTracker(
6061
credentials_provider=mock_credentials_provider,
@@ -87,6 +88,40 @@ async def test_emit_tool_called_event_enabled(self):
8788
assert tool_called.dbt_cloud_environment_id_prod == "1"
8889
assert tool_called.dbt_cloud_user_id == "3"
8990
assert tool_called.local_user_id == "local-user"
91+
assert tool_called.ctx.dbt_cloud_account_identifier == "ab123"
92+
93+
@pytest.mark.asyncio
94+
async def test_emit_tool_called_event_account_identifier_none(self):
95+
"""When account_identifier is None, dbt_cloud_account_identifier defaults to empty string."""
96+
mock_settings = DbtMcpSettings.model_construct(
97+
do_not_track=None,
98+
send_anonymous_usage_data=None,
99+
)
100+
101+
tracker = DefaultUsageTracker(
102+
credentials_provider=MockCredentialsProvider(mock_settings),
103+
session_id=uuid.uuid4(),
104+
)
105+
106+
with (
107+
patch("dbt_mcp.tracking.tracking.log_proto") as mock_log_proto,
108+
patch(
109+
"dbt_mcp.tracking.tracking.DefaultUsageTracker._get_local_user_id",
110+
return_value="local-user",
111+
),
112+
):
113+
await tracker.emit_tool_called_event(
114+
tool_called_event=ToolCalledEvent(
115+
tool_name="list_metrics",
116+
arguments={},
117+
start_time_ms=0,
118+
end_time_ms=1,
119+
error_message=None,
120+
),
121+
)
122+
123+
tool_called = mock_log_proto.call_args.args[0]
124+
assert tool_called.ctx.dbt_cloud_account_identifier == ""
90125

91126
@pytest.mark.asyncio
92127
async def test_get_local_user_id_success(self):

0 commit comments

Comments
 (0)