Skip to content

feat(toolsets/datadog): multi-account support for all four Datadog toolsets - #2064

Closed
mdecalf wants to merge 5 commits into
HolmesGPT:masterfrom
mdecalf:feature/datadog-multi-account
Closed

mdecalf wants to merge 5 commits into
HolmesGPT:masterfrom
mdecalf:feature/datadog-multi-account

Conversation

@mdecalf

@mdecalf mdecalf commented May 18, 2026 •

Copy link
Copy Markdown

Closes #2065

Problem

Many organizations run separate Datadog organizations for staging and production. Until now a single Holmes instance could only query one Datadog org per toolset, which meant either running two Holmes deployments or writing brittle custom-toolset YAML. This PR adds first-class multi-account support to all four Datadog toolsets while keeping the existing single-account configuration entirely unchanged.

Solution

A new accounts: list on each toolset config lets users declare N Datadog accounts. The LLM picks the target account on every tool call via a new optional account parameter. When only one account is configured (including all existing deployments), the parameter is a no-op — behavior is identical to today.

Before (unchanged, still works)

toolsets:
  datadog/metrics:
    enabled: true
    config:
      api_key: "{{ env.DD_API_KEY }}"
      app_key: "{{ env.DD_APP_KEY }}"
      api_url: https://api.datadoghq.com

After (new multi-account form)

toolsets:
  datadog/metrics:
    enabled: true
    config:
      accounts:
        - name: staging
          api_key: "{{ env.DD_API_KEY_STG }}"
          app_key: "{{ env.DD_APP_KEY_STG }}"
          api_url: https://api.datadoghq.eu
          default: true
        - name: production
          api_key: "{{ env.DD_API_KEY_PRD }}"
          app_key: "{{ env.DD_APP_KEY_PRD }}"
          api_url: https://api.datadoghq.eu

Changes

datadog_api.py

  • New DatadogAccount model holds per-account credentials (name, api_key, app_key, api_url, default). api_url defaults to https://api.datadoghq.com preserving the existing upstream default.
  • DatadogBaseConfig gains accounts: List[DatadogAccount]. A @model_validator normalises the single-account shorthand into a one-element [DatadogAccount(name="default", ...)], so zero migration is required.
  • _ui_required_fields = ["api_key", "app_key"] keeps the UI form marking credentials as required; _hidden_fields = ["accounts"] hides the complex nested field from the form UI (advanced users configure it via YAML).
  • get_account(name) resolves the per-call account name, or returns the default; raises KeyError listing available names so the LLM can self-correct on retry.
  • get_headers(account) now takes a DatadogAccount instead of the full config.

Four toolsets (logs, metrics, traces, general)

  • Every tool gains an optional account string parameter.
  • _invoke resolves the account via get_account() and passes it to get_headers() and all URL constructors.
  • _perform_healthcheck takes a DatadogAccount; prerequisites_callable iterates all accounts and fails fast on the first unhealthy one, embedding the account name in the error message.
  • _reload_instructions() passes the validated dd_config to the Jinja context so the system prompt lists available accounts and the default.

Jinja instruction templates (all four)

Added a guard block listing configured accounts only when more than one is present. Single-account users see no change in their system prompt.

datadog_url_utils.py

URL generators accept DatadogAccount instead of the full typed config, decoupling URL building from toolset config.

Tests (67 pass, 16 skipped/slow)

  • tests/plugins/toolsets/datadog/test_datadog_accounts.py (new, 11 tests): config parsing for both schemas, validation edge cases, and get_account() resolution.
  • Account-routing tests added to all four toolset test files.
  • Updated assertions in existing tests to match the new account-scoped healthcheck error messages.

Docs

Added a "Multiple Datadog accounts" section to docs/data-sources/builtin-toolsets/datadog.md.

Backward compatibility

Scenario Status
Existing api_key/app_key/api_url config ✅ unchanged
Existing config without api_url (defaults to US1) ✅ unchanged
Deprecated aliases (dd_api_key, site_api_url, …) ✅ still work
Single toolset YAML, no accounts: key ✅ auto-normalised
Multi-account accounts: list ✅ new path
Both forms supplied together ❌ ValueError (intentional)

Note on llm_instructions timing

_reload_instructions() is called from both __init__ (renders with dd_config=None, template guard emits nothing) and from prerequisites_callable (renders with the validated config including the account list). Because check_prerequisites runs before the first LLM conversation in all deployment paths, the account list is always present in the system prompt when it matters.

Summary by CodeRabbit

  • New Features

    • Datadog toolset: configure multiple accounts (with legacy single-account shorthand preserved), per-request account parameter, per-account health checks; omitting account uses the configured/default account.
  • Documentation

    • Updated usage and troubleshooting instructions to list configured accounts, mark the default, and show how to target a specific account.
  • Tests

    • Added tests for multi-account configs, resolver behavior, per-account routing, and healthcheck/error messaging across logs, metrics, traces, and general toolsets.

Review Change Stack

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review

This pull request is from a fork — automated review is disabled. A repository maintainer can comment @claude review to run a one-time review.

@linux-foundation-easycla

linux-foundation-easycla Bot commented May 18, 2026 •

Copy link
Copy Markdown

CLA Signed
The committers listed above are authorized under a signed CLA.

  • ✅ login: mdecalf / name: mdecalf (95c9475)

@coderabbitai

coderabbitai Bot commented May 18, 2026 •

Copy link
Copy Markdown
Contributor

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Adds Datadog multi-account support: new DatadogAccount model and config normalization, refactors URL/header helpers to use per-account credentials, adds optional account tool parameter across Datadog tools, performs per-account healthchecks, injects accounts into Jinja instructions, and adds tests and docs.

Changes

Multi-account Datadog support

Layer / File(s) Summary
Account configuration model and validation
holmes/plugins/toolsets/datadog/datadog_api.py, tests/plugins/toolsets/datadog/test_datadog_accounts.py
Introduces DatadogAccount model; extends DatadogBaseConfig with accounts and an after-validator that normalizes legacy shorthand into a default account, enforces mutual exclusivity and unique names, ensures a single default, and adds get_account(); get_headers() now accepts DatadogAccount.
URL helper refactor for account-based routing
holmes/plugins/toolsets/datadog/datadog_url_utils.py
Refactors URL generators to accept DatadogAccount and derive base_url from account.api_url; adds generate_datadog_metric_metadata_url; updates logs URL builder to accept explicit indexes.
LLM instructions and user docs
holmes/core/tools.py, holmes/plugins/toolsets/datadog/*.jinja2, docs/data-sources/builtin-toolsets/datadog.md
Toolset methods accept extra_context for Jinja rendering; Datadog Jinja templates render an "accounts" section when multiple accounts are configured; docs add a "Multiple Datadog accounts" section describing accounts: syntax and per-request account routing.
General toolset multi-account support
holmes/plugins/toolsets/datadog/toolset_datadog_general.py, tests/plugins/toolsets/datadog/test_toolset_datadog_general.py
DatadogGeneralToolset validates each configured account during prerequisites and reloads instructions with account list; DatadogAPIGet and DatadogAPIPostSearch gain optional account parameters, resolve accounts via dd_config.get_account(...), and use account-scoped URLs/headers and error messages.
Logs toolset multi-account support
holmes/plugins/toolsets/datadog/toolset_datadog_logs.py, tests/plugins/toolsets/datadog/logs/test_check_prerequisites.py
Per-account healthchecks and instruction reloads; GetLogs adds optional account param, resolves it, and builds search requests and returned URLs using account.api_url and get_headers(account); errors include account context.
Metrics toolset multi-account support
holmes/plugins/toolsets/datadog/toolset_datadog_metrics.py, tests/plugins/toolsets/datadog/metrics/test_datadog_metrics.py
Adds _resolve_account; metrics tools add optional account param, resolve it and route requests using account.api_url and get_headers(account); toolset validates all accounts and reloads instructions with dd_config in context.
Traces toolset multi-account support
holmes/plugins/toolsets/datadog/toolset_datadog_traces.py, tests/plugins/toolsets/datadog/traces/test_datadog_traces.py
Per-account healthchecks and instruction reloads; GetSpans and AggregateSpans add account param, resolve accounts via dd_config.get_account(...), and build spans/analytics requests, headers, and web URLs from the selected account; 403 healthcheck messaging is tailored.
Tests for account routing and validation
tests/plugins/toolsets/datadog/*
Adds unit tests covering legacy shorthand normalization, accounts: validation, get_account() resolution, per-tool account routing, and account-scoped healthcheck/error messages across logs, metrics, traces, and general toolsets.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • HolmesGPT/holmesgpt#1493: Both PRs modify holmes/plugins/toolsets/datadog/datadog_api.py around DatadogBaseConfig and header generation (get_headers), but the main PR changes it to be account-centric for multi-account routing while the retrieved PR only renames/deprecates config fields.
  • HolmesGPT/holmesgpt#911: Both PRs modify the Datadog “general” toolset implementation—main PR adds multi-account support and an optional account parameter (routing headers/URLs via dd_config.get_account) to DatadogGeneralToolset/DatadogAPIGet/DatadogAPIPostSearch.
  • HolmesGPT/holmesgpt#648: Related to the Datadog traces toolset and its instructions; this PR extends traces with multi-account routing and account-aware healthchecks.

Suggested reviewers

  • moshemorad
  • arikalon1
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 26.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title accurately summarizes the main change: adding multi-account support to all four Datadog toolsets.
Linked Issues check ✅ Passed The PR implementation fully satisfies all coding requirements from issue #2065: multi-account configuration via accounts list, account parameter on tools, backward compatibility with single-account shorthand, account name injection into system prompts, and comprehensive tests.
Out of Scope Changes check ✅ Passed All changes are directly scoped to multi-account Datadog support: configuration models, account resolution logic, URL/header builders, tool parameter additions, instruction templates, and comprehensive tests.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@netlify

netlify Bot commented May 18, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for holmes-docs ready!

Name Link
🔨 Latest commit 7177280
🔍 Latest deploy log https://app.netlify.com/projects/holmes-docs/deploys/6a26c65436ae9500070b9998
😎 Deploy Preview https://deploy-preview-2064--holmes-docs.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (9)
tests/plugins/toolsets/datadog/test_datadog_accounts.py (1)

35-41: ⚡ Quick win

Add a regression test for blank credential values.

Coverage is strong, but this suite should also assert that empty/whitespace api_key/app_key are rejected (not just missing fields). That prevents silent auth misconfiguration regressions.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/plugins/toolsets/datadog/test_datadog_accounts.py` around lines 35 -
41, Add a regression test that asserts blank or whitespace credential values are
rejected: create a new test (e.g., test_blank_credentials_raises) that calls
_base_config with api_key="" and api_key="   " and similarly with app_key
empty/whitespace, and verify each invocation raises ValueError (use
pytest.raises with an appropriate match like "missing credentials" or "blank").
Place this alongside test_shorthand_missing_field_raises and
test_no_credentials_raises so the suite covers missing, blank, and
whitespace-only credential cases.
holmes/plugins/toolsets/datadog/toolset_datadog_traces.py (1)

155-162: ⚡ Quick win

Hoist load_and_render_prompt import to file scope.

Line 161 currently imports inside _reload_instructions; move it to top-level imports.

As per coding guidelines: "**/*.py: Always place Python imports at the top of the file, not inside functions or methods".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@holmes/plugins/toolsets/datadog/toolset_datadog_traces.py` around lines 155 -
162, Move the local import of load_and_render_prompt out of the
_reload_instructions method and into the module-level imports: remove "from
holmes.plugins.prompts import load_and_render_prompt" from inside the
_reload_instructions function and add it to the top of the file alongside other
imports so the symbol load_and_render_prompt (from holmes.plugins.prompts) is
imported at file scope and can be referenced directly within
_reload_instructions.
holmes/plugins/toolsets/datadog/toolset_datadog_general.py (1)

309-316: ⚡ Quick win

Move _reload_instructions import to module scope.

Line 315 imports load_and_render_prompt inside the method. Please move it to top-level imports to match repo Python conventions.

As per coding guidelines: "**/*.py: Always place Python imports at the top of the file, not inside functions or methods".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@holmes/plugins/toolsets/datadog/toolset_datadog_general.py` around lines 309
- 316, The method _reload_instructions currently imports load_and_render_prompt
from holmes.plugins.prompts inside the function; move that import to module
top-level imports so load_and_render_prompt is imported once when the module
loads and to follow project conventions. Update the file-level imports to
include "from holmes.plugins.prompts import load_and_render_prompt" and remove
the inline import inside _reload_instructions, ensuring any references to
load_and_render_prompt inside _reload_instructions remain unchanged.
holmes/plugins/toolsets/datadog/toolset_datadog_logs.py (1)

163-170: ⚡ Quick win

Move load_and_render_prompt import to top-level.

Line 169 imports inside _reload_instructions; please move it to module imports.

As per coding guidelines: "**/*.py: Always place Python imports at the top of the file, not inside functions or methods".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@holmes/plugins/toolsets/datadog/toolset_datadog_logs.py` around lines 163 -
170, The import of load_and_render_prompt is currently inside the
_reload_instructions method; move the import statement to the module top-level
imports so it follows the project's import guidelines and reference
load_and_render_prompt from the module scope. Update the file imports section to
include "from holmes.plugins.prompts import load_and_render_prompt" and remove
the in-method import in _reload_instructions; if a circular-import error appears
when moving it, add a short comment explaining the circular dependency and keep
a single localized import with that explanation instead of duplicating imports.
tests/plugins/toolsets/datadog/test_toolset_datadog_general.py (2)

181-185: ⚡ Quick win

Avoid function-local imports in setup_method.

Lines 182-185 add imports inside the test method. Move them to module scope for consistency with the repo Python style.

As per coding guidelines: "**/*.py: Always place Python imports at the top of the file, not inside functions or methods".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/plugins/toolsets/datadog/test_toolset_datadog_general.py` around lines
181 - 185, The test's setup_method currently contains function-local imports for
DatadogGeneralConfig and DatadogGeneralToolset; move these two imports out of
setup_method to the top-level of the test module so they are imported at module
scope (referencing DatadogGeneralConfig and DatadogGeneralToolset) and remove
the in-method import statements in setup_method to comply with the project's
import style.

207-224: ⚡ Quick win

Use responses instead of patching requests.get in tests.

These tests patch holmes.plugins.toolsets.datadog.datadog_api.requests.get; please switch to responses so HTTP behavior is mocked at the transport layer.

As per coding guidelines: "tests/**/*.py: Use the responses library for HTTP mocking in tests, not @patch('requests.get'), as it intercepts at the transport/adapter level for more realistic test behavior".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/plugins/toolsets/datadog/test_toolset_datadog_general.py` around lines
207 - 224, Replace the
`@patch`("holmes.plugins.toolsets.datadog.datadog_api.requests.get") usage in the
tests test_account_param_routes_to_target and test_omitted_account_uses_default
by using the responses library: call responses.activate (or the responses
fixture), register a GET response for the expected base URL with
responses.add(...) returning status=200 and the JSON body {"data": []}, then
invoke get_tool._invoke (DatadogAPIGet via self.toolset.tools[0]) with the same
context and params; finally assert against responses.calls to verify the
requested URL starts with "https://api.stg.datadoghq.eu" and that the outgoing
request headers include "DD-API-KEY" == "stg-key" (instead of inspecting
mock_get.call_args). Ensure both tests follow this pattern and remove the `@patch`
decorator and mock_get usage.
holmes/plugins/toolsets/datadog/toolset_datadog_metrics.py (1)

859-868: ⚡ Quick win

Move _reload_instructions import to module scope.

Line 867 uses a function-local import; please hoist it to top-level imports.

As per coding guidelines: "**/*.py: Always place Python imports at the top of the file, not inside functions or methods".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@holmes/plugins/toolsets/datadog/toolset_datadog_metrics.py` around lines 859
- 868, The method _reload_instructions currently does a function-local import of
load_and_render_prompt; hoist that import to module scope by adding "from
holmes.plugins.prompts import load_and_render_prompt" to the file's top-level
imports and remove the in-function import inside _reload_instructions so the
method simply calls load_and_render_prompt directly; ensure no other references
rely on the local import and run tests/lint after the change.
tests/plugins/toolsets/datadog/traces/test_datadog_traces.py (1)

166-169: ⚡ Quick win

Move setup imports to module top.

Lines 167-169 import modules inside setup_method; please move these imports to module scope.

As per coding guidelines: "**/*.py: Always place Python imports at the top of the file, not inside functions or methods".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/plugins/toolsets/datadog/traces/test_datadog_traces.py` around lines
166 - 169, The imports for DatadogAccount and DatadogTracesConfig are currently
inside setup_method; move "from holmes.plugins.toolsets.datadog.datadog_api
import DatadogAccount" and "from holmes.plugins.toolsets.datadog.datadog_models
import DatadogTracesConfig" to the top of the module (module scope) and remove
them from inside the setup_method so the test file follows the guideline of
placing imports at file top and setup_method just references those symbols.
tests/plugins/toolsets/datadog/metrics/test_datadog_metrics.py (1)

353-385: ⚡ Quick win

Prefer responses over patching requests.get in routing tests.

The new account-routing tests should mock transport using responses instead of patching holmes.plugins.toolsets.datadog.datadog_api.requests.get.

As per coding guidelines: "tests/**/*.py: Use the responses library for HTTP mocking in tests, not @patch('requests.get'), as it intercepts at the transport/adapter level for more realistic test behavior".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/plugins/toolsets/datadog/metrics/test_datadog_metrics.py` around lines
353 - 385, The tests test_account_param_routes_to_target_url_and_keys and
test_omitted_account_uses_default are patching
holmes.plugins.toolsets.datadog.datadog_api.requests.get which violates the
testing guideline; replace the requests.get patch with the responses library by
registering the expected Datadog endpoint(s) and response bodies (matching the
URL patterns used by the ListActiveMetrics tool and its tool._invoke call),
assert the call was made by inspecting responses.calls (and verify the request
URL and DD-API-KEY / DD-APPLICATION-KEY headers), and remove the `@patch`
decorator so the HTTP interaction is intercepted at the transport/adapter level
instead of mocking requests.get directly.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@holmes/plugins/toolsets/datadog/datadog_api.py`:
- Around line 118-127: The config model currently accepts empty or
whitespace-only strings for api_key and app_key (the Field declarations named
api_key and app_key), causing opaque auth failures later; add validation to
reject blank credentials during model validation by trimming and checking
non-empty (e.g. a Pydantic validator or use constr with
min_length/strip_whitespace) and raise a clear ValueError when api_key or
app_key are empty after strip; apply the same non-blank validation to the other
shorthand/alternate credential fields referenced around the second occurrence
(lines 257-271) so both accounts and shorthand credential paths fail fast with a
clear error.

In `@holmes/plugins/toolsets/datadog/datadog_url_utils.py`:
- Around line 119-120: The code currently sets url_params["index"] when indexes
!= ["*"], which still applies a filter if "*" appears alongside other entries;
update the logic in datadog_url_utils (the block using the indexes variable and
url_params dict) to treat any indexes list containing "*" as unfiltered—i.e.,
only set url_params["index"] when "*" is not present in indexes (use a
membership check like '*' in indexes to decide), so that lists containing "*" do
not produce an index query parameter.

---

Nitpick comments:
In `@holmes/plugins/toolsets/datadog/toolset_datadog_general.py`:
- Around line 309-316: The method _reload_instructions currently imports
load_and_render_prompt from holmes.plugins.prompts inside the function; move
that import to module top-level imports so load_and_render_prompt is imported
once when the module loads and to follow project conventions. Update the
file-level imports to include "from holmes.plugins.prompts import
load_and_render_prompt" and remove the inline import inside
_reload_instructions, ensuring any references to load_and_render_prompt inside
_reload_instructions remain unchanged.

In `@holmes/plugins/toolsets/datadog/toolset_datadog_logs.py`:
- Around line 163-170: The import of load_and_render_prompt is currently inside
the _reload_instructions method; move the import statement to the module
top-level imports so it follows the project's import guidelines and reference
load_and_render_prompt from the module scope. Update the file imports section to
include "from holmes.plugins.prompts import load_and_render_prompt" and remove
the in-method import in _reload_instructions; if a circular-import error appears
when moving it, add a short comment explaining the circular dependency and keep
a single localized import with that explanation instead of duplicating imports.

In `@holmes/plugins/toolsets/datadog/toolset_datadog_metrics.py`:
- Around line 859-868: The method _reload_instructions currently does a
function-local import of load_and_render_prompt; hoist that import to module
scope by adding "from holmes.plugins.prompts import load_and_render_prompt" to
the file's top-level imports and remove the in-function import inside
_reload_instructions so the method simply calls load_and_render_prompt directly;
ensure no other references rely on the local import and run tests/lint after the
change.

In `@holmes/plugins/toolsets/datadog/toolset_datadog_traces.py`:
- Around line 155-162: Move the local import of load_and_render_prompt out of
the _reload_instructions method and into the module-level imports: remove "from
holmes.plugins.prompts import load_and_render_prompt" from inside the
_reload_instructions function and add it to the top of the file alongside other
imports so the symbol load_and_render_prompt (from holmes.plugins.prompts) is
imported at file scope and can be referenced directly within
_reload_instructions.

In `@tests/plugins/toolsets/datadog/metrics/test_datadog_metrics.py`:
- Around line 353-385: The tests
test_account_param_routes_to_target_url_and_keys and
test_omitted_account_uses_default are patching
holmes.plugins.toolsets.datadog.datadog_api.requests.get which violates the
testing guideline; replace the requests.get patch with the responses library by
registering the expected Datadog endpoint(s) and response bodies (matching the
URL patterns used by the ListActiveMetrics tool and its tool._invoke call),
assert the call was made by inspecting responses.calls (and verify the request
URL and DD-API-KEY / DD-APPLICATION-KEY headers), and remove the `@patch`
decorator so the HTTP interaction is intercepted at the transport/adapter level
instead of mocking requests.get directly.

In `@tests/plugins/toolsets/datadog/test_datadog_accounts.py`:
- Around line 35-41: Add a regression test that asserts blank or whitespace
credential values are rejected: create a new test (e.g.,
test_blank_credentials_raises) that calls _base_config with api_key="" and
api_key="   " and similarly with app_key empty/whitespace, and verify each
invocation raises ValueError (use pytest.raises with an appropriate match like
"missing credentials" or "blank"). Place this alongside
test_shorthand_missing_field_raises and test_no_credentials_raises so the suite
covers missing, blank, and whitespace-only credential cases.

In `@tests/plugins/toolsets/datadog/test_toolset_datadog_general.py`:
- Around line 181-185: The test's setup_method currently contains function-local
imports for DatadogGeneralConfig and DatadogGeneralToolset; move these two
imports out of setup_method to the top-level of the test module so they are
imported at module scope (referencing DatadogGeneralConfig and
DatadogGeneralToolset) and remove the in-method import statements in
setup_method to comply with the project's import style.
- Around line 207-224: Replace the
`@patch`("holmes.plugins.toolsets.datadog.datadog_api.requests.get") usage in the
tests test_account_param_routes_to_target and test_omitted_account_uses_default
by using the responses library: call responses.activate (or the responses
fixture), register a GET response for the expected base URL with
responses.add(...) returning status=200 and the JSON body {"data": []}, then
invoke get_tool._invoke (DatadogAPIGet via self.toolset.tools[0]) with the same
context and params; finally assert against responses.calls to verify the
requested URL starts with "https://api.stg.datadoghq.eu" and that the outgoing
request headers include "DD-API-KEY" == "stg-key" (instead of inspecting
mock_get.call_args). Ensure both tests follow this pattern and remove the `@patch`
decorator and mock_get usage.

In `@tests/plugins/toolsets/datadog/traces/test_datadog_traces.py`:
- Around line 166-169: The imports for DatadogAccount and DatadogTracesConfig
are currently inside setup_method; move "from
holmes.plugins.toolsets.datadog.datadog_api import DatadogAccount" and "from
holmes.plugins.toolsets.datadog.datadog_models import DatadogTracesConfig" to
the top of the module (module scope) and remove them from inside the
setup_method so the test file follows the guideline of placing imports at file
top and setup_method just references those symbols.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: ebba76fb-a1db-4336-ac87-6819a620df56

📥 Commits

Reviewing files that changed from the base of the PR and between 8fd1844 and 95c9475.

📒 Files selected for processing (16)
  • docs/data-sources/builtin-toolsets/datadog.md
  • holmes/plugins/toolsets/datadog/datadog_api.py
  • holmes/plugins/toolsets/datadog/datadog_general_instructions.jinja2
  • holmes/plugins/toolsets/datadog/datadog_logs_instructions.jinja2
  • holmes/plugins/toolsets/datadog/datadog_metrics_instructions.jinja2
  • holmes/plugins/toolsets/datadog/datadog_url_utils.py
  • holmes/plugins/toolsets/datadog/instructions_datadog_traces.jinja2
  • holmes/plugins/toolsets/datadog/toolset_datadog_general.py
  • holmes/plugins/toolsets/datadog/toolset_datadog_logs.py
  • holmes/plugins/toolsets/datadog/toolset_datadog_metrics.py
  • holmes/plugins/toolsets/datadog/toolset_datadog_traces.py
  • tests/plugins/toolsets/datadog/logs/test_check_prerequisites.py
  • tests/plugins/toolsets/datadog/metrics/test_datadog_metrics.py
  • tests/plugins/toolsets/datadog/test_datadog_accounts.py
  • tests/plugins/toolsets/datadog/test_toolset_datadog_general.py
  • tests/plugins/toolsets/datadog/traces/test_datadog_traces.py

Comment thread holmes/plugins/toolsets/datadog/datadog_api.py
Comment thread holmes/plugins/toolsets/datadog/datadog_url_utils.py Outdated

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@holmes/plugins/toolsets/datadog/datadog_api.py`:
- Around line 273-281: The code creates a DatadogAccount in self.accounts and
unconditionally passes api_url=self.api_url, which breaks Pydantic validation
when self.api_url is None; instead, only include the api_url argument when
self.api_url is not None (e.g. build account_kwargs =
{"name":"default","api_key":self.api_key,"app_key":self.app_key,"default":True}
and add "api_url": self.api_url to account_kwargs only if self.api_url is not
None) and then instantiate DatadogAccount(**account_kwargs) so legacy configs
that omit api_url keep the model's default.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 968173e4-b5ee-443b-918e-7930c51b58b1

📥 Commits

Reviewing files that changed from the base of the PR and between 95c9475 and 011c92c.

📒 Files selected for processing (1)
  • holmes/plugins/toolsets/datadog/datadog_api.py

Comment thread holmes/plugins/toolsets/datadog/datadog_api.py Outdated
@mdecalf
mdecalf force-pushed the feature/datadog-multi-account branch from 011c92c to b9c92f6 Compare May 18, 2026 18:24
@mdecalf mdecalf changed the title feat(datadog): multi-account support for all four Datadog toolsets feat(toolsets/datadog): multi-account support for all four Datadog toolsets May 18, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (2)
tests/plugins/toolsets/datadog/test_toolset_datadog_general.py (1)

182-185: ⚡ Quick win

Move these imports to module scope.

Lines 182-183 contain imports inside setup_method(), which violates the coding guideline that Python imports must be placed at the top of the file. Move DatadogGeneralConfig and the DatadogGeneralToolset import to the module-level import block (note that DatadogGeneralToolset is already imported at module level, so remove the duplicate).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/plugins/toolsets/datadog/test_toolset_datadog_general.py` around lines
182 - 185, The imports for DatadogGeneralConfig and DatadogGeneralToolset are
inside setup_method(), violating the module-scope import guideline; move the
DatadogGeneralConfig import to the top import block and remove the duplicate
DatadogGeneralToolset import (since DatadogGeneralToolset is already imported at
module level) so both symbols are imported at module scope and setup_method() no
longer contains any imports.
tests/plugins/toolsets/datadog/traces/test_datadog_traces.py (1)

167-169: ⚡ Quick win

Move method-local imports to the file import section.

These imports should be moved to module scope to match the Python import guideline used in this repo. According to the coding guidelines: "Always place Python imports at the top of the file, not inside functions or methods."

from holmes.plugins.toolsets.datadog.datadog_api import DatadogAccount
from holmes.plugins.toolsets.datadog.datadog_models import DatadogTracesConfig
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/plugins/toolsets/datadog/traces/test_datadog_traces.py` around lines
167 - 169, Move the method-local imports for DatadogAccount and
DatadogTracesConfig out of the test method and add them to the module-level
import section at the top of the test file; locate the local imports referencing
holmes.plugins.toolsets.datadog.datadog_api.DatadogAccount and
holmes.plugins.toolsets.datadog.datadog_models.DatadogTracesConfig, remove them
from inside the test function, and place equivalent import statements with those
symbols in the file's import block so the tests use the top-level imports.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/data-sources/builtin-toolsets/datadog.md`:
- Around line 200-215: Convert the fenced YAML code block to an indented code
block to satisfy MD046: replace the triple-backtick fenced block containing the
"toolsets: datadog/metrics: enabled: true config: accounts: ..." example with a
properly indented code block (prefix each line with four spaces) so the YAML
snippet remains intact; locate the example by searching for the "toolsets",
"datadog/metrics", "config", and "accounts" keys and update that block only.

In `@holmes/plugins/toolsets/datadog/toolset_datadog_metrics.py`:
- Line 867: The import of load_and_render_prompt is currently inside the
_reload_instructions() method; move that import to the module-level import block
at the top of the file so it is no longer nested in the function. Update the top
imports to include "from holmes.plugins.prompts import load_and_render_prompt"
and remove the in-function import inside _reload_instructions() to match other
toolsets (e.g., bash_toolset.py, prometheus.py) and comply with the project's
import guidelines.

In `@tests/plugins/toolsets/datadog/test_toolset_datadog_general.py`:
- Around line 207-224: Replace the `@patch-based` HTTP mocking in the test
functions (e.g., test_account_param_routes_to_target and
test_omitted_account_uses_default) with the responses library: wrap the
invocation of DatadogAPIGet._invoke inside a with responses.RequestsMock() as
rsps: block, register the expected GET using rsps.add(responses.GET,
expected_url, json={"data": []}, status=200) where expected_url matches the
https://api.stg.datadoghq.eu... or default domain used by the tool, then run
get_tool._invoke(...) and assert behavior by inspecting the response/result and
any outbound request headers or URLs via responses (e.g., checking rsps.calls or
the registered URL) instead of using mock_get.call_args; update both
test_account_param_routes_to_target and test_omitted_account_uses_default
accordingly.

---

Nitpick comments:
In `@tests/plugins/toolsets/datadog/test_toolset_datadog_general.py`:
- Around line 182-185: The imports for DatadogGeneralConfig and
DatadogGeneralToolset are inside setup_method(), violating the module-scope
import guideline; move the DatadogGeneralConfig import to the top import block
and remove the duplicate DatadogGeneralToolset import (since
DatadogGeneralToolset is already imported at module level) so both symbols are
imported at module scope and setup_method() no longer contains any imports.

In `@tests/plugins/toolsets/datadog/traces/test_datadog_traces.py`:
- Around line 167-169: Move the method-local imports for DatadogAccount and
DatadogTracesConfig out of the test method and add them to the module-level
import section at the top of the test file; locate the local imports referencing
holmes.plugins.toolsets.datadog.datadog_api.DatadogAccount and
holmes.plugins.toolsets.datadog.datadog_models.DatadogTracesConfig, remove them
from inside the test function, and place equivalent import statements with those
symbols in the file's import block so the tests use the top-level imports.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: bee091b5-4c9b-4428-a1ee-f3820c79e2cb

📥 Commits

Reviewing files that changed from the base of the PR and between 011c92c and b9c92f6.

📒 Files selected for processing (16)
  • docs/data-sources/builtin-toolsets/datadog.md
  • holmes/plugins/toolsets/datadog/datadog_api.py
  • holmes/plugins/toolsets/datadog/datadog_general_instructions.jinja2
  • holmes/plugins/toolsets/datadog/datadog_logs_instructions.jinja2
  • holmes/plugins/toolsets/datadog/datadog_metrics_instructions.jinja2
  • holmes/plugins/toolsets/datadog/datadog_url_utils.py
  • holmes/plugins/toolsets/datadog/instructions_datadog_traces.jinja2
  • holmes/plugins/toolsets/datadog/toolset_datadog_general.py
  • holmes/plugins/toolsets/datadog/toolset_datadog_logs.py
  • holmes/plugins/toolsets/datadog/toolset_datadog_metrics.py
  • holmes/plugins/toolsets/datadog/toolset_datadog_traces.py
  • tests/plugins/toolsets/datadog/logs/test_check_prerequisites.py
  • tests/plugins/toolsets/datadog/metrics/test_datadog_metrics.py
  • tests/plugins/toolsets/datadog/test_datadog_accounts.py
  • tests/plugins/toolsets/datadog/test_toolset_datadog_general.py
  • tests/plugins/toolsets/datadog/traces/test_datadog_traces.py
✅ Files skipped from review due to trivial changes (1)
  • holmes/plugins/toolsets/datadog/datadog_metrics_instructions.jinja2
🚧 Files skipped from review as they are similar to previous changes (11)
  • holmes/plugins/toolsets/datadog/datadog_logs_instructions.jinja2
  • holmes/plugins/toolsets/datadog/datadog_general_instructions.jinja2
  • holmes/plugins/toolsets/datadog/instructions_datadog_traces.jinja2
  • tests/plugins/toolsets/datadog/metrics/test_datadog_metrics.py
  • tests/plugins/toolsets/datadog/test_datadog_accounts.py
  • holmes/plugins/toolsets/datadog/toolset_datadog_logs.py
  • holmes/plugins/toolsets/datadog/toolset_datadog_traces.py
  • holmes/plugins/toolsets/datadog/toolset_datadog_general.py
  • holmes/plugins/toolsets/datadog/datadog_api.py
  • holmes/plugins/toolsets/datadog/datadog_url_utils.py
  • tests/plugins/toolsets/datadog/logs/test_check_prerequisites.py

Comment thread docs/data-sources/builtin-toolsets/datadog.md Outdated
Comment thread holmes/plugins/toolsets/datadog/toolset_datadog_metrics.py Outdated
Comment thread tests/plugins/toolsets/datadog/test_toolset_datadog_general.py Outdated
@mdecalf
mdecalf force-pushed the feature/datadog-multi-account branch from b9c92f6 to 5a1933b Compare May 18, 2026 18:40

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
holmes/plugins/toolsets/datadog/toolset_datadog_metrics.py (1)

693-751: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Preserve the failed request invocation for list_datadog_metric_tags.

invocation can never be populated here: the request sends {} inline, query_params stays None, and the final truthiness check also suppresses empty params. That drops the exact account-scoped endpoint from failures, which makes self-correction much harder.

🛠️ Minimal fix
-        api_url = None
-        query_params = None
+        api_url = None
+        query_params = {}
@@
             data = execute_datadog_http_request(
                 url=api_url,
                 headers=headers,
                 timeout=self.toolset.dd_config.timeout_seconds,
                 method="GET",
-                payload_or_params={},
+                payload_or_params=query_params,
             )
@@
                 error=error_msg,
                 params=params,
                 invocation=json.dumps({"url": api_url, "params": query_params})
-                if api_url and query_params
+                if api_url is not None
                 else None,
             )

As per coding guidelines, "All toolsets MUST return detailed error messages from underlying APIs to enable LLM self-correction, including the exact query/command executed, time ranges/parameters/filters used, and full API error response (status code and message)".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@holmes/plugins/toolsets/datadog/toolset_datadog_metrics.py` around lines 693
- 751, The error path currently never records the actual request because
query_params remains None and the call uses an inline {}; change the flow so
query_params is set (e.g., query_params = {}) before calling
execute_datadog_http_request and pass query_params as the payload_or_params
argument (execute_datadog_http_request(..., payload_or_params=query_params)),
then in the exception return include the preserved invocation by JSON-encoding
{"url": api_url, "params": query_params, "headers": headers} (use the existing
api_url, query_params and headers variables) so list_datadog_metric_tags /
execute_datadog_http_request failures include the exact request details.
🧹 Nitpick comments (1)
holmes/plugins/toolsets/datadog/toolset_datadog_metrics.py (1)

840-853: ⚡ Quick win

Publish self.dd_config only after every account passes healthcheck.

self.dd_config is set before the account loop completes. If a later account fails, the toolset keeps an unvalidated config bound on the instance while the status/instructions still reflect the previous state. Keeping it local until the loop succeeds makes the state transition atomic.

♻️ Suggested change
         try:
             dd_config = DatadogMetricsConfig(**config)
-            self.dd_config = dd_config
 
             for account in dd_config.accounts:
                 success, error_msg = self._perform_healthcheck(account, dd_config)
                 if not success:
                     return False, error_msg
 
+            self.dd_config = dd_config
             # Re-render the LLM instructions now that the validated config
             # (with its account list) is bound on the toolset, so the LLM
             # sees the available account names.
             self._reload_instructions()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@holmes/plugins/toolsets/datadog/toolset_datadog_metrics.py` around lines 840
- 853, The code sets self.dd_config before validating all accounts which can
leave an unvalidated config on the instance if a later account fails; instead
keep the parsed DatadogMetricsConfig in a local variable
(DatadogMetricsConfig(**config)), run the account loop using
_perform_healthcheck for each account, and only assign to self.dd_config after
every account check succeeds, then call _reload_instructions; ensure that on any
failure you return (False, error_msg) without mutating self.dd_config.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@holmes/plugins/toolsets/datadog/toolset_datadog_metrics.py`:
- Around line 693-751: The error path currently never records the actual request
because query_params remains None and the call uses an inline {}; change the
flow so query_params is set (e.g., query_params = {}) before calling
execute_datadog_http_request and pass query_params as the payload_or_params
argument (execute_datadog_http_request(..., payload_or_params=query_params)),
then in the exception return include the preserved invocation by JSON-encoding
{"url": api_url, "params": query_params, "headers": headers} (use the existing
api_url, query_params and headers variables) so list_datadog_metric_tags /
execute_datadog_http_request failures include the exact request details.

---

Nitpick comments:
In `@holmes/plugins/toolsets/datadog/toolset_datadog_metrics.py`:
- Around line 840-853: The code sets self.dd_config before validating all
accounts which can leave an unvalidated config on the instance if a later
account fails; instead keep the parsed DatadogMetricsConfig in a local variable
(DatadogMetricsConfig(**config)), run the account loop using
_perform_healthcheck for each account, and only assign to self.dd_config after
every account check succeeds, then call _reload_instructions; ensure that on any
failure you return (False, error_msg) without mutating self.dd_config.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: e5fcdedc-4b08-4d4d-b65d-2a4df1f43060

📥 Commits

Reviewing files that changed from the base of the PR and between b9c92f6 and 5a1933b.

📒 Files selected for processing (17)
  • docs/data-sources/builtin-toolsets/datadog.md
  • holmes/core/tools.py
  • holmes/plugins/toolsets/datadog/datadog_api.py
  • holmes/plugins/toolsets/datadog/datadog_general_instructions.jinja2
  • holmes/plugins/toolsets/datadog/datadog_logs_instructions.jinja2
  • holmes/plugins/toolsets/datadog/datadog_metrics_instructions.jinja2
  • holmes/plugins/toolsets/datadog/datadog_url_utils.py
  • holmes/plugins/toolsets/datadog/instructions_datadog_traces.jinja2
  • holmes/plugins/toolsets/datadog/toolset_datadog_general.py
  • holmes/plugins/toolsets/datadog/toolset_datadog_logs.py
  • holmes/plugins/toolsets/datadog/toolset_datadog_metrics.py
  • holmes/plugins/toolsets/datadog/toolset_datadog_traces.py
  • tests/plugins/toolsets/datadog/logs/test_check_prerequisites.py
  • tests/plugins/toolsets/datadog/metrics/test_datadog_metrics.py
  • tests/plugins/toolsets/datadog/test_datadog_accounts.py
  • tests/plugins/toolsets/datadog/test_toolset_datadog_general.py
  • tests/plugins/toolsets/datadog/traces/test_datadog_traces.py
✅ Files skipped from review due to trivial changes (1)
  • docs/data-sources/builtin-toolsets/datadog.md
🚧 Files skipped from review as they are similar to previous changes (13)
  • holmes/plugins/toolsets/datadog/datadog_metrics_instructions.jinja2
  • holmes/plugins/toolsets/datadog/datadog_logs_instructions.jinja2
  • holmes/plugins/toolsets/datadog/instructions_datadog_traces.jinja2
  • tests/plugins/toolsets/datadog/test_datadog_accounts.py
  • tests/plugins/toolsets/datadog/traces/test_datadog_traces.py
  • tests/plugins/toolsets/datadog/logs/test_check_prerequisites.py
  • tests/plugins/toolsets/datadog/test_toolset_datadog_general.py
  • holmes/plugins/toolsets/datadog/toolset_datadog_logs.py
  • holmes/plugins/toolsets/datadog/toolset_datadog_traces.py
  • tests/plugins/toolsets/datadog/metrics/test_datadog_metrics.py
  • holmes/plugins/toolsets/datadog/toolset_datadog_general.py
  • holmes/plugins/toolsets/datadog/datadog_api.py
  • holmes/plugins/toolsets/datadog/datadog_url_utils.py

@mdecalf
mdecalf force-pushed the feature/datadog-multi-account branch 2 times, most recently from acdfae7 to 8c68429 Compare May 19, 2026 08:01
@mdecalf
mdecalf marked this pull request as draft May 19, 2026 08:02
Many organizations run separate Datadog orgs for staging and production.
Until now a single Holmes instance could only query one Datadog org per
toolset, which meant maintaining two Holmes deployments or writing
brittle custom toolset YAML.

This change adds first-class multi-account support to all four Datadog
toolsets (logs, metrics, traces, general) while keeping full backward
compatibility with the existing single-account configuration.

## Config schema (datadog_api.py)
- New DatadogAccount Pydantic model holds per-account credentials
  (name, api_key, app_key, api_url, default). api_url defaults to
  https://api.datadoghq.com (US1), preserving the upstream default.
  api_key and app_key have min_length=1 to fail fast on blank strings.
- DatadogBaseConfig gains accounts: List[DatadogAccount]. A
  model_validator normalises the legacy single-account shorthand
  (api_key/app_key/api_url at top level) into a one-element list named
  'default'. api_url is omitted from the DatadogAccount constructor
  when None so the field default applies correctly.
- _ui_required_fields = ['api_key', 'app_key'] keeps the UI schema
  requiring credentials; _hidden_fields = ['accounts'] hides the
  complex nested field from form UIs.
- get_account(name) resolves the per-call account name or returns the
  default; raises KeyError listing valid names for LLM self-correction.
- get_headers(account) takes a DatadogAccount instead of the full config.

## Per-toolset changes (logs, metrics, traces, general)
- Every tool gains an optional account string parameter.
- _invoke resolves the account via get_account() and uses it for
  get_headers() and all URL constructors.
- _perform_healthcheck takes a DatadogAccount; prerequisites_callable
  iterates all accounts and fails fast on the first unhealthy one.
- _reload_instructions() passes dd_config to the Jinja context so the
  system prompt lists available accounts when more than one is present.

## Jinja instruction templates (all four)
Guard block listing accounts only when more than one is configured.
Single-account users see no change in their system prompt.

## datadog_url_utils.py
- URL generators accept DatadogAccount instead of the full typed config.
- generate_datadog_logs_url: use '*' not in indexes instead of
  indexes \!= ['*'] to treat any list containing '*' as unfiltered.

## Tests (67 pass, 16 skipped/slow)
- New test_datadog_accounts.py: config parsing, validation edge cases,
  get_account() resolution.
- Account-routing tests for all four toolsets.
- Updated existing assertions for new account-scoped error messages.

## Docs
Added 'Multiple Datadog accounts' section to the Datadog toolset docs.

Signed-off-by: mdecalf <maxime.decalf@ledger.fr>
@mdecalf
mdecalf force-pushed the feature/datadog-multi-account branch from 8c68429 to c34e7fa Compare May 19, 2026 08:22
@mdecalf

mdecalf commented May 19, 2026

Copy link
Copy Markdown
Author

@coderabbitai review

@coderabbitai

coderabbitai Bot commented May 19, 2026

Copy link
Copy Markdown
Contributor
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@mdecalf
mdecalf marked this pull request as ready for review May 19, 2026 08:34
@aantn

aantn commented May 26, 2026

Copy link
Copy Markdown
Collaborator

@claude review

Comment on lines +182 to +184
# directly in their YAML/Helm values.
_ui_required_fields: ClassVar[List[str]] = ["api_key", "app_key"]
_hidden_fields: ClassVar[List[str]] = ["accounts"]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 The new _hidden_fields = ["accounts"] on DatadogBaseConfig (datadog_api.py:184) is silently overridden for two of the four sibling toolsets: DatadogTracesConfig and DatadogLogsConfig already define their own _hidden_fields = ["indexes"] in datadog_models.py:50,65, and a ClassVar redefined in a subclass shadows the parent's rather than merging. Because build_schema_entry and build_config_example in holmes/utils/pydantic_utils.py read the attribute directly (no MRO walk), the nested accounts field leaks into both the form-UI JSON schema and the generated YAML example for datadog/logs and datadog/traces — directly contradicting the comment immediately above line 184. Easiest fix: extend each subclass to _hidden_fields = ["accounts", "indexes"]; alternatively, change the two consumers in pydantic_utils.py to walk the MRO and union the values.

Extended reasoning...

What the bug is. This PR adds _hidden_fields: ClassVar[List[str]] = ["accounts"] on DatadogBaseConfig (holmes/plugins/toolsets/datadog/datadog_api.py:184) with the explicit goal — stated in the comment immediately above the line — of hiding the nested accounts list from the form UI and the generated YAML example, because nested complex objects don't render well in form inputs. This works for the two sibling subclasses that don't otherwise touch the attribute (DatadogMetricsConfig at datadog_models.py:36, DatadogGeneralConfig at datadog_models.py:121). It silently breaks for the other two:\n\n- DatadogTracesConfig (holmes/plugins/toolsets/datadog/datadog_models.py:50): _hidden_fields: ClassVar[List[str]] = ["indexes"]\n- DatadogLogsConfig (holmes/plugins/toolsets/datadog/datadog_models.py:65): _hidden_fields: ClassVar[List[str]] = ["indexes"]\n\nBoth of those subclasses redefine the ClassVar — which in Python shadows rather than merges with the parent — so for those two classes cls._hidden_fields == ["indexes"] and accounts is not hidden at all.\n\nThe code path. Both consumers in holmes/utils/pydantic_utils.py read the attribute directly off the most-derived class, with no MRO walking:\n\n- build_schema_entry (pydantic_utils.py:103): hidden = list(cls._hidden_fields or []) — feeds Toolset.get_config_schema(), which is what the frontend's config form consumes.\n- build_config_example (pydantic_utils.py:235): hidden_fields = set(getattr(model_cls, "_hidden_fields", []) or []) — feeds the generated YAML example.\n\nSo for the datadog/logs and datadog/traces toolsets, the form schema gets the complex nested accounts field as a top-level form field, and the YAML example emits accounts: [] — the exact opposite of the PR's stated UX goal. The four sibling toolsets are now gratuitously inconsistent.\n\nWhy existing tests don't catch it. The new tests/plugins/toolsets/datadog/test_datadog_accounts.py suite exercises only the validator and the get_account() resolver. Nothing covers build_schema_entry / build_config_example on DatadogLogsConfig / DatadogTracesConfig, so the regression sails through CI.\n\nImpact. UI/UX regression introduced by this PR (not a runtime crash): two of the four toolset config forms expose a nested object input the author explicitly tried to hide, and two of the four example YAMLs include a stray accounts: [] line that confuses single-account users. The intent is documented inline; the code doesn't match it.\n\nStep-by-step proof. Take the on-disk classes:\n\npython\nclass DatadogBaseConfig(ToolsetConfig):\n _hidden_fields: ClassVar[List[str]] = ["accounts"] # datadog_api.py:184\n ...\n\nclass DatadogLogsConfig(DatadogBaseConfig):\n _hidden_fields: ClassVar[List[str]] = ["indexes"] # datadog_models.py:65\n indexes: list[str] = Field(default_factory=lambda: ["*"])\n ...\n\nclass DatadogTracesConfig(DatadogBaseConfig):\n _hidden_fields: ClassVar[List[str]] = ["indexes"] # datadog_models.py:50\n indexes: list[str] = Field(default_factory=lambda: ["*"])\n\n\n1. DatadogLogsConfig._hidden_fields → ["indexes"] (NOT ["accounts", "indexes"]) — standard Python class attribute shadowing.\n2. build_schema_entry(DatadogLogsConfig) does hidden = list(cls._hidden_fields or []) → ["indexes"], then pops only "indexes" from raw_schema["properties"]. The schema returned to the frontend therefore contains accounts as a top-level property (with its nested DatadogAccount object shape).\n3. build_config_example(DatadogLogsConfig) does hidden_fields = set(getattr(model_cls, "_hidden_fields", []) or []) → {"indexes"}. The loop over model_cls.model_fields.items() then emits a key for accounts (whose default_factory=list produces []), so the example YAML contains accounts: [].\n4. Same reasoning, by identical class structure, applies to DatadogTracesConfig.\n5. Compare to DatadogMetricsConfig / DatadogGeneralConfig — neither redefines _hidden_fields, so attribute lookup walks up to DatadogBaseConfig and returns ["accounts"], correctly hiding the field. Hence the gratuitous inconsistency across the four sibling toolsets, with the two more-customised ones being the broken pair.\n\nFix. Either of:\n\n1. Local one-line per subclass — extend the lists so they union with the parent's intent: _hidden_fields: ClassVar[List[str]] = ["accounts", "indexes"] on DatadogLogsConfig and DatadogTracesConfig. Trivial and keeps the consumers simple.\n2. Structural — change the two consumers in holmes/utils/pydantic_utils.py (build_schema_entry and build_config_example) to merge _hidden_fields across the MRO (e.g. set().union(*[getattr(c, "_hidden_fields", []) or [] for c in cls.__mro__])), so subclass overrides extend rather than replace. This makes the contract less surprising for any future subclass.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 55be474. Went with the subclass-extension approach: both DatadogTracesConfig and DatadogLogsConfig now declare _hidden_fields = ["accounts", "indexes"] explicitly, so the parent's "accounts" is never dropped by ClassVar shadowing. Skipped the MRO-walking alternative in pydantic_utils.py to keep the change minimal and localised.

Comment on lines 149 to +156
dd_config = DatadogLogsConfig(**config)
self.dd_config = dd_config

success, error_msg = self._perform_healthcheck()
return success, error_msg
for account in dd_config.accounts:
success, error_msg = self._perform_healthcheck(account)
if not success:
return False, error_msg
self._reload_instructions()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Nit: inconsistent with the rest of this PR — self.dd_config = dd_config is assigned before the per-account healthcheck loop (line 150) here, whereas the refactor in this PR moved it after the loop in the metrics, traces, and general toolsets. The structural reason is that _perform_healthcheck reads self.dd_config.indexes and self.dd_config.timeout_seconds from the instance rather than from a parameter. Real user-visible impact is small (status=FAILED gates tool invocation), but the fix is trivial: change _perform_healthcheck(self, account) to _perform_healthcheck(self, account, dd_config), read dd_config.indexes / dd_config.timeout_seconds, and move self.dd_config = dd_config to after the loop — matching the pattern the PR already applies to the other three sibling toolsets.

Extended reasoning...

What's inconsistent

This PR deliberately refactored prerequisites_callable in three of the four Datadog toolsets so that self.dd_config is mutated only after the per-account healthcheck loop succeeds:

  • toolset_datadog_metrics.py:838-848 — loop first, self.dd_config = dd_config after.
  • toolset_datadog_traces.py:78-83 — loop first, self.dd_config = dd_config after.
  • toolset_datadog_general.py:254-260 — loop first, self.dd_config = dd_config after.

The logs toolset kept the pre-PR ordering — assignment happens at toolset_datadog_logs.py:150, before the new multi-account loop at lines 152-155:

dd_config = DatadogLogsConfig(**config)
self.dd_config = dd_config            # <-- assigned BEFORE the loop
for account in dd_config.accounts:
    success, error_msg = self._perform_healthcheck(account)
    if not success:
        return False, error_msg

Why logs was written this way

The other three toolsets pass dd_config as a parameter into _perform_healthcheck(self, account, dd_config). The logs version takes only (self, account) and reads self.dd_config.indexes (line 98) and self.dd_config.timeout_seconds (line 108) off the instance attribute — that's what forces self.dd_config to be populated before the loop runs.

Concrete reasoning, step by step

  1. A previously-healthy DatadogLogsToolset is sitting with a valid self.dd_config (e.g. account A — healthy).
  2. A reconfigure code path re-invokes prerequisites_callable(new_config) on the same instance. (ToolsetManager.refresh_server_toolsets_and_get_changes / refresh_toolsets_and_get_changes can re-run prerequisites on existing toolset instances in some flows.)
  3. The new config has two accounts; account[1]'s credentials are wrong.
  4. Line 150 runs first: self.dd_config = new_dd_config. The previously-valid config is now gone.
  5. The loop hits account[1], healthcheck fails, function returns (False, error_msg).
  6. The toolset transitions to status=FAILED, but self.dd_config now references the half-validated new config (account[0] passed, account[1] failed).

In the metrics/traces/general toolsets, step 4 doesn't happen — self.dd_config retains its prior valid value because the assignment is gated on the loop succeeding.

Why I'm calling this a nit, not a normal bug

The failure path requires both (a) a reconfigure flow that re-invokes prerequisites_callable on a live instance, and (b) a caller that reads self.toolset.dd_config without first checking status. The standard tool-invocation path is gated by tool_executor on status == FAILED and rejects the call with "Toolset is unavailable", and _reload_instructions is only called on the success path (line 156). So the user-visible blast radius is essentially nil today.

That said, the inconsistency with the PR's own pattern for the three sibling toolsets is real, the new multi-account loop is what introduces the partial-validation surface area (pre-PR there was a single healthcheck call, so the half-validated state wasn't reachable), and the fix is mechanical.

Suggested fix

Match what the PR already does for the other three toolsets:

def _perform_healthcheck(
    self, account: DatadogAccount, dd_config: DatadogLogsConfig
) -> Tuple[bool, str]:
    headers = get_headers(account)
    payload = {
        "filter": {
            "from": "now-1m",
            "to": "now",
            "query": "*",
            "indexes": dd_config.indexes,         # was self.dd_config.indexes
        },
        "page": {"limit": 1},
    }
    search_url = f"{account.api_url}/api/v2/logs/events/search"
    execute_datadog_http_request(
        url=search_url,
        headers=headers,
        payload_or_params=payload,
        timeout=dd_config.timeout_seconds,        # was self.dd_config.timeout_seconds
        method="POST",
    )
    ...

def prerequisites_callable(self, config: dict[str, Any]) -> Tuple[bool, str]:
    ...
    dd_config = DatadogLogsConfig(**config)
    for account in dd_config.accounts:
        success, error_msg = self._perform_healthcheck(account, dd_config)
        if not success:
            return False, error_msg
    self.dd_config = dd_config        # moved here, matches the other three
    self._reload_instructions()
    return True, ""

The if not self.dd_config guard at the top of _perform_healthcheck can also be dropped since the function no longer depends on instance state.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 55be474. _perform_healthcheck now takes dd_config: DatadogLogsConfig as an explicit parameter (reads dd_config.indexes / dd_config.timeout_seconds directly instead of self.dd_config), the if not self.dd_config guard removed, and self.dd_config = dd_config moved to after the per-account loop — identical pattern to metrics and traces.

Comment on lines +140 to +143
default_time_span_seconds=ACTIVE_METRICS_DEFAULT_TIME_SPAN_SECONDS,
)

url = f"{self.toolset.dd_config.api_url}/api/v1/metrics"
headers = get_headers(self.toolset.dd_config)
url = f"{account.api_url}/api/v1/metrics"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟣 Pre-existing: URL builders in the logs, metrics, and traces toolsets use raw f-string concatenation against account.api_url (e.g. f"{account.api_url}/api/v1/metrics" at toolset_datadog_metrics.py:143), but Pydantic v2 AnyUrl normalizes any user-supplied URL by appending a trailing slash — so a user setting api_url: https://api.datadoghq.eu yields https://api.datadoghq.eu//api/v1/metrics. The datadog/general toolset already does the correct str(account.api_url).rstrip("/") (toolset_datadog_general.py:470, 679); the same one-liner is missing from the other three toolsets at logs:103,258; metrics:143,360,581,699,800; traces:116,294,668. Existing behavior — pre-PR dd_config.api_url was the same AnyUrl type and produced the same double slash — but since this PR refactors every URL builder, it's a natural opportunity to fix it consistently (ideally by stripping the slash once in a DatadogAccount validator). The new account-routing tests only assert startswith(...) so they don't catch the bug.

Extended reasoning...

What's happening

Pydantic v2's AnyUrl validator normalizes URLs supplied by users by appending a trailing slash to the path. The literal default value declared on the field stays unnormalized (because Pydantic v2 doesn't run validators on field defaults unless validate_default=True), but any user-provided value is normalized. Reproducer:

from pydantic import BaseModel, AnyUrl, Field
class A(BaseModel): url: AnyUrl = Field(default="https://api.datadoghq.com")
str(A().url)                                  # 'https://api.datadoghq.com'    (no slash — default literal stays as-is)
str(A(url='https://api.datadoghq.eu').url)    # 'https://api.datadoghq.eu/'    (trailing slash appended)

How it manifests in this PR

The new DatadogAccount model has api_url: AnyUrl = Field(default="https://api.datadoghq.com"). In the new multi-account accounts: list form, every account MUST supply api_url (there's no default-from-parent behavior in _normalize_accounts), so the trailing slash always gets appended. The legacy shorthand also hits this when a user explicitly sets api_url.

Then 10 sites refactored by this PR build URLs by raw f-string concat:

  • toolset_datadog_logs.py:103, 258
  • toolset_datadog_metrics.py:143, 360, 581, 699, 800
  • toolset_datadog_traces.py:116, 294, 668

All of the form f"{account.api_url}/api/v2/…" — producing https://api.datadoghq.eu//api/v2/… for any user-supplied URL.

Step-by-step proof

  1. User configures accounts: [{name: prod, api_key: …, app_key: …, api_url: https://api.datadoghq.eu}].
  2. Pydantic constructs DatadogAccount(api_url=AnyUrl('https://api.datadoghq.eu')) → normalized to https://api.datadoghq.eu/.
  3. Tool call hits e.g. ListActiveMetrics._invoke at toolset_datadog_metrics.py:140-143:
    url = f"{account.api_url}/api/v1/metrics"
  4. str(AnyUrl('https://api.datadoghq.eu/')) is 'https://api.datadoghq.eu/', so the f-string produces 'https://api.datadoghq.eu//api/v1/metrics' — note the double slash before api.

Why existing code doesn't prevent it

The PR author was clearly aware of the issue: toolset_datadog_general.py:470, 679 (and datadog_url_utils.py's convert_api_url_to_app_url which explicitly strips the trailing slash) defensively use str(account.api_url).rstrip("/"). The pattern just wasn't carried over when refactoring the other three toolsets. The new account-routing tests only assert url.startswith("https://api.stg.datadoghq.eu"), which a double-slash URL passes trivially, so the bug isn't caught by the new test suite.

Impact

Datadog's edge/LB tolerance for // in paths varies by region. In many regions Datadog silently normalizes the path and the call succeeds; in others (or behind certain WAF/edge configs) the request 400s. So this is a latent reliability bug whose symptoms depend on which Datadog site a user is on. It also produces ugly, inconsistent URLs in logs and error messages.

Why pre-existing

Before this PR, DatadogBaseConfig.api_url was a required AnyUrl (also normalized by Pydantic), and pre-PR call sites had the same f-string pattern (e.g. pre-PR f"{self.toolset.dd_config.api_url}/api/v1/metrics"). So users who explicitly set api_url were already affected. The PR doesn't introduce the bug or worsen its reach — it just preserves the pre-existing inconsistency between general (rstrip applied) and logs/metrics/traces (no rstrip). The submitter's claim that the new accounts: list "materially increases reach" because api_url is mandatory there is misleading — pre-PR api_url was also required.

Suggested fix

The cleanest fix is one-line, applied once on the model so every consumer is safe — add a validator to DatadogAccount:

@field_validator("api_url", mode="after")
@classmethod
def _strip_trailing_slash(cls, v: AnyUrl) -> AnyUrl:
    s = str(v).rstrip("/")
    return AnyUrl(s)

Alternatively, apply str(account.api_url).rstrip("/") at the 10 missing call sites to match general. This is a good opportunity-of-fix since the PR is already touching every affected line.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 55be474. Applied str(account.api_url).rstrip("/") at all 10 URL construction sites across logs (2), metrics (5) and traces (3), consistent with the pattern already used in toolset_datadog_general.py. Went with the call-site approach rather than a DatadogAccount validator to avoid Pydantic v2 AnyUrl type reconstruction complexity.

mdecalf added 2 commits June 8, 2026 14:41
- Extend _hidden_fields in DatadogTracesConfig and DatadogLogsConfig to
  include "accounts" alongside "indexes", preventing the parent ClassVar
  from being shadowed and leaking `accounts` into the form-UI JSON schema.

- Align DatadogLogsToolset._perform_healthcheck signature with the metrics
  and traces pattern: accept dd_config as an explicit parameter and move
  self.dd_config assignment to after the per-account healthcheck loop.

- Add str(...).rstrip("/") to all 10 URL construction sites in logs,
  metrics and traces toolsets to prevent Pydantic v2 AnyUrl trailing-slash
  from producing double-slash paths (e.g. https://api.datadoghq.eu//api/v1).
  Consistent with the existing pattern already used in the general toolset.
@mdecalf

mdecalf commented Jun 12, 2026

Copy link
Copy Markdown
Author

@aantn Following PR #2115 I guess we can close this one

@mdecalf mdecalf closed this Jun 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature Request: Support multiple Datadog accounts in a single Holmes instance

2 participants