Skip to content

LCORE-1276 Add configuration snapshot for telemetry that masks PII#1155

Open
max-svistunov wants to merge 3 commits intolightspeed-core:mainfrom
max-svistunov:lcore-1276-mask-pii
Open

LCORE-1276 Add configuration snapshot for telemetry that masks PII#1155
max-svistunov wants to merge 3 commits intolightspeed-core:mainfrom
max-svistunov:lcore-1276-mask-pii

Conversation

@max-svistunov
Copy link
Contributor

@max-svistunov max-svistunov commented Feb 16, 2026

Description

Add configuration snapshot for telemetry that masks PII. New module builds JSON-serializable snapshots of both lightspeed-stack and llama-stack configurations at startup. It's opt-in, not opt-out, so additions don't get leaked, but they need to be explicitly added.

Type of change

  • Refactor
  • New feature
  • Bug fix
  • CVE fix
  • Optimization
  • Documentation Update
  • Configuration Update
  • Bump-up service version
  • Bump-up dependent library
  • Bump-up library or tool used for development (does not change the final image)
  • CI configuration change
  • Konflux configuration change
  • Unit tests improvement
  • Integration tests improvement
  • End to end tests improvement
  • Benchmarks improvement

Tools used to create PR

Identify any AI code assistants used in this PR (for transparency and review context)

  • Assisted-by: Claude Opus 4.6
  • Generated by: Claude Opus 4.6

Related Tickets & Documents

  • Related Issue # LCORE-1276
  • Closes # LCORE-1276

Checklist before requesting a review

  • I have performed a self-review of my code.
  • PR has passed all pre-merge test jobs.
  • If it is a core feature, I have added thorough tests.

Testing

uv run python -c "
import json, sys, tempfile, yaml
sys.path.insert(0, 'tests/unit/telemetry')
from conftest import build_fully_populated_config, SAMPLE_LLAMA_STACK_CONFIG
from telemetry.configuration_snapshot import build_configuration_snapshot
config = build_fully_populated_config()
with tempfile.NamedTemporaryFile(mode='w', suffix='.yaml', delete=False) as f:
    yaml.dump(SAMPLE_LLAMA_STACK_CONFIG, f)
snapshot = build_configuration_snapshot(config, f.name)
print(json.dumps(snapshot, indent=2))
"

Output should show "configured" / "not_configured" for sensitive fields (e.g. API keys), but normal values for non-sensitive (e.g. port numbers).

Summary by CodeRabbit

  • New Features

    • Added configuration snapshot capability that emits JSON-serializable, per-field masked configuration telemetry.
  • Documentation

    • Added package-level documentation describing snapshot behavior and masking semantics.
  • Tests

    • Added comprehensive unit tests and test scaffolding validating extraction, masking, serialization, registries, and sample configs.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 16, 2026

Walkthrough

Adds a new telemetry package that builds JSON-serializable, PII-masked configuration snapshots from lightspeed-stack and llama-stack sources, plus unit tests and fixtures validating extraction, masking, and serialization.

Changes

Cohort / File(s) Summary
Telemetry Core
src/telemetry/__init__.py, src/telemetry/configuration_snapshot.py
New telemetry package initializer and a large module implementing configuration snapshot construction: constants (CONFIGURED, NOT_CONFIGURED, NOT_AVAILABLE), MaskingType enum, FieldSpec/ListFieldSpec dataclasses, allowlisted registries for lightspeed/llama stacks, nested extraction helpers, masking/serialization pipeline, YAML reader, and public APIs build_lightspeed_stack_snapshot(), build_llama_stack_snapshot(), build_configuration_snapshot().
Test Infrastructure
tests/unit/telemetry/__init__.py, tests/unit/telemetry/conftest.py
Adds telemetry test package init and fixtures/helpers: PII constants, SAMPLE_LLAMA_STACK_CONFIG, LLAMA_STACK_PII_VALUES, build_fully_populated_config(), build_minimal_config(), and a llama_stack_config_file pytest fixture writing a temp YAML config.
Test Suite
tests/unit/telemetry/test_configuration_snapshot.py
Comprehensive unit tests covering nested extraction, serialization, masking semantics (PASSTHROUGH vs SENSITIVE), list handling, store extraction, lightspeed/llama snapshot behaviors, PII leakage checks, registry validation, and JSON serializability across scenarios.

Sequence Diagram(s)

sequenceDiagram
    participant Client as Client
    participant BuildAPI as build_configuration_snapshot()
    participant Lightspeed as build_lightspeed_stack_snapshot()
    participant Llama as build_llama_stack_snapshot()
    participant ExtractMask as FieldExtraction&Masking
    participant Serialize as JSONSerialization

    Client->>BuildAPI: invoke with Configuration + optional llama_config_path
    BuildAPI->>Lightspeed: build lightspeed snapshot
    Lightspeed->>ExtractMask: traverse LIGHTSPEED_STACK_FIELDS
    ExtractMask->>ExtractMask: apply PASSTHROUGH / SENSITIVE masking
    ExtractMask->>Serialize: convert to JSON-serializable values
    Serialize-->>Lightspeed: return masked lightspeed snapshot
    BuildAPI->>Llama: build llama snapshot (reads YAML if path provided)
    Llama->>ExtractMask: traverse LLAMA_STACK_FIELDS and lists
    ExtractMask->>ExtractMask: per-item extraction & masking
    ExtractMask->>Serialize: convert nested/list items
    Serialize-->>Llama: return masked llama snapshot
    BuildAPI->>BuildAPI: merge lightspeed + llama snapshots
    BuildAPI-->>Client: return combined masked configuration snapshot
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

🚥 Pre-merge checks | ✅ 3 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Merge Conflict Detection ⚠️ Warning ❌ Merge conflicts detected (3 files):

⚔️ pyproject.toml (content)
⚔️ tests/benchmarks/test_app_database.py (content)
⚔️ uv.lock (content)

These conflicts must be resolved before merging into main.
Resolve conflicts locally and push changes to this branch.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main change: adding a configuration snapshot feature for telemetry with PII masking, which aligns with all the core changes across the new telemetry module files and comprehensive tests.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

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

✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
⚔️ Resolve merge conflicts (beta)
  • Auto-commit resolved conflicts to branch lcore-1276-mask-pii
  • Post resolved changes as copyable diffs in a comment

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.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@tests/unit/telemetry/conftest.py`:
- Around line 170-298: The fixture build_fully_populated_config constructs
TLSConfiguration.tls_key_password with PurePosixPath which mismatches the
model's FilePath/Path type; update the TLSConfiguration construction in
build_fully_populated_config to use Path(PII_TLS_PASS) or a plain string for
tls_key_password (instead of PurePosixPath) so the test's types match
TLSConfiguration.tls_key_password and reflect real validation.
🧹 Nitpick comments (5)
src/telemetry/configuration_snapshot.py (3)

300-314: _set_nested_value assumes no path is a prefix of another path.

If a FieldSpec path like "a.b" is registered alongside "a.b.c", the first call stores a scalar at target["a"]["b"], and the second call tries to traverse into it as a dict — causing an AttributeError/TypeError at runtime. The current registries are safe, but this is fragile for future maintainers.

Consider adding a guard or an assertion:

🛡️ Defensive check
     parts = path.split(".")
     current = target
     for part in parts[:-1]:
-        if part not in current:
+        if part not in current or not isinstance(current[part], dict):
             current[part] = {}
         current = current[part]
     current[parts[-1]] = value

260-280: str(value) fallback in passthrough could leak unexpected types.

The final else str(value) branch converts unknown types to their string representation. If a new config type is introduced that embeds PII in __str__, it would leak through passthrough. The SecretStr/PurePath guards are good defense-in-depth, but consider logging a warning when hitting the fallback branch so future regressions are visible.

💡 Optional: add warning on fallback
     # Safety: mask SecretStr and file paths; stringify everything else
-    return CONFIGURED if isinstance(value, (SecretStr, PurePath)) else str(value)
+    if isinstance(value, (SecretStr, PurePath)):
+        return CONFIGURED
+    logger.debug("Passthrough fallback to str() for type %s", type(value).__name__)
+    return str(value)

160-228: Provider loop is clean — verify the provider list stays in sync with llama-stack.

The hardcoded provider names (inference, safety, vector_io, …) will silently miss any new provider types added to llama-stack in the future. This is fine given the allowlist design intent, but consider adding a comment noting this list should be updated when llama-stack adds new provider categories.

tests/unit/telemetry/test_configuration_snapshot.py (2)

505-512: Temp file cleanup pattern could be more robust.

In test_invalid_yaml, test_missing_providers_section, and test_server_fields_masked, temp files are created with delete=False and cleaned up inline. If build_llama_stack_snapshot raises an unexpected exception, the cleanup line is skipped. Consider using pytest's tmp_path fixture instead, which auto-cleans regardless of test outcome.

💡 Example using tmp_path
-    def test_invalid_yaml(self) -> None:
+    def test_invalid_yaml(self, tmp_path: Path) -> None:
         """Test that invalid YAML returns not_available status."""
-        with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", delete=False) as f:
-            f.write(": invalid: yaml: [")
-            path = f.name
-        result = build_llama_stack_snapshot(path)
-        Path(path).unlink()
+        path = tmp_path / "invalid.yaml"
+        path.write_text(": invalid: yaml: [")
+        result = build_llama_stack_snapshot(str(path))
         assert result == {"status": NOT_AVAILABLE}

640-647: Allowlist check only validates top-level keys.

test_snapshot_only_contains_allowlisted_fields checks that no unexpected top-level keys exist, but doesn't verify nested keys. For example, if a bug in _set_nested_value introduced extra nested keys, this test wouldn't catch it. The PII leak tests provide a stronger guarantee, so this is mostly a belt-and-suspenders concern.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

🧹 Nitpick comments (3)
src/telemetry/configuration_snapshot.py (2)

262-284: Safety-net fallback to str() could silently leak sensitive data for unexpected types.

If a new Pydantic field type is introduced that isn't SecretStr, PurePath, or a primitive, _serialize_passthrough will call str() on it, potentially exposing sensitive content. The debug-level log makes this easy to miss in production. Consider using warning level or defaulting to CONFIGURED for unknown types to err on the side of safety.

🛡️ Safer fallback proposal
     # Safety: mask SecretStr and file paths; log and stringify everything else
-    if not isinstance(value, (SecretStr, PurePath)):
-        logger.debug("Passthrough fallback to str() for type %s", type(value).__name__)
-    return CONFIGURED if isinstance(value, (SecretStr, PurePath)) else str(value)
+    logger.warning("Passthrough fallback for unexpected type %s — masking as configured", type(value).__name__)
+    return CONFIGURED

335-360: Return type list[dict[str, Any]] | str is imprecise — consider a dedicated sentinel or typed union.

The function returns either a list or the string literal "not_configured". Downstream consumers must check isinstance(result, str) to distinguish. Using Literal["not_configured"] in the union would make the contract clearer, though this is a minor API polish point.

tests/unit/telemetry/test_configuration_snapshot.py (1)

699-709: Duplicate detection has O(n²) complexity — minor but easily improved.

The paths.count(p) inside a list comprehension iterates the full list for each element. For the current registry sizes this is negligible, but a Counter or set approach would be cleaner.

♻️ Simpler duplicate detection
     def test_no_duplicate_paths_in_lightspeed_registry(self) -> None:
         """Verify no duplicate paths in lightspeed-stack registry."""
         paths = [spec.path for spec in LIGHTSPEED_STACK_FIELDS]
-        duplicates = [p for p in paths if paths.count(p) > 1]
-        assert not duplicates, f"Duplicate paths: {set(duplicates)}"
+        seen: set[str] = set()
+        duplicates = {p for p in paths if p in seen or seen.add(p)}  # type: ignore[func-returns-value]
+        assert not duplicates, f"Duplicate paths: {duplicates}"

@max-svistunov
Copy link
Contributor Author

@are-ces , could you PTAL?

A few notes:

  • Snapshots cover both lightspeed-stack config and llama-stack config, but the latter only in library mode. Do we need to cover the service mode too? In that case, I can add querying llama-stack to fetch the config.

  • List fields preserve item details -- each item's subfields are masked individually and not collapsed into a single configured for the whole list.

  • For providers, only provider_id and provider_type are collected.

  • The spreadsheet linked in the jira has "wants" columns per product (OLS, Ansible etc). I included all fields regardless of product. Should we change to per product, or is collecting everything fine?

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

🤖 Fix all issues with AI agents
Verify each finding against the current code and only fix it if needed.


In `@src/telemetry/configuration_snapshot.py`:
- Around line 205-225: The current ListFieldSpec generator uses a hardcoded
tuple of provider category names; change it to iterate the actual provider keys
from the parsed config (e.g., use the dict keys from the variable holding
providers in the snapshot builder or Config object) so new llama-stack provider
categories are picked up automatically, while keeping the per-item FieldSpec
masking of "provider_id" and "provider_type" with MaskingType.PASSTHROUGH;
update the expression that creates the ListFieldSpec instances (the generator
producing ListFieldSpec(f"providers.{provider_name}",
item_fields=(FieldSpec("provider_id", ...), FieldSpec("provider_type", ...))))
to use dynamic provider_name values from the config's providers dict instead of
the hardcoded tuple.
- Around line 419-422: The snapshot currently treats db_path as not configured
when backend.get("db_path") is falsy; update the check in the result dict so
db_path is considered CONFIGURED whenever it is present (non-None) by using an
explicit is not None test (e.g., use CONFIGURED if backend.get("db_path") is not
None else NOT_CONFIGURED) to match the mask_value SENSITIVE handling and avoid
treating empty string as not configured; modify the code that builds result (the
dict with keys "type" and "db_path") accordingly.

In `@tests/unit/telemetry/test_configuration_snapshot.py`:
- Around line 32-38: The tests explicitly import helper functions
build_fully_populated_config and build_minimal_config from conftest; convert
those helpers into pytest fixtures in conftest (e.g., def
build_fully_populated_config() -> Config marked with `@pytest.fixture` and
similarly for build_minimal_config) and then update tests to accept them as
function arguments instead of importing them, so test_configuration_snapshot.py
uses dependency-injected fixtures rather than direct imports while keeping
existing helper names (build_fully_populated_config, build_minimal_config) for
minimal test changes.
🧹 Nitpick comments (3)
🤖 Fix all nitpicks with AI agents
Verify each finding against the current code and only fix it if needed.


In `@src/telemetry/configuration_snapshot.py`:
- Around line 205-225: The current ListFieldSpec generator uses a hardcoded
tuple of provider category names; change it to iterate the actual provider keys
from the parsed config (e.g., use the dict keys from the variable holding
providers in the snapshot builder or Config object) so new llama-stack provider
categories are picked up automatically, while keeping the per-item FieldSpec
masking of "provider_id" and "provider_type" with MaskingType.PASSTHROUGH;
update the expression that creates the ListFieldSpec instances (the generator
producing ListFieldSpec(f"providers.{provider_name}",
item_fields=(FieldSpec("provider_id", ...), FieldSpec("provider_type", ...))))
to use dynamic provider_name values from the config's providers dict instead of
the hardcoded tuple.
- Around line 419-422: The snapshot currently treats db_path as not configured
when backend.get("db_path") is falsy; update the check in the result dict so
db_path is considered CONFIGURED whenever it is present (non-None) by using an
explicit is not None test (e.g., use CONFIGURED if backend.get("db_path") is not
None else NOT_CONFIGURED) to match the mask_value SENSITIVE handling and avoid
treating empty string as not configured; modify the code that builds result (the
dict with keys "type" and "db_path") accordingly.

In `@tests/unit/telemetry/test_configuration_snapshot.py`:
- Around line 32-38: The tests explicitly import helper functions
build_fully_populated_config and build_minimal_config from conftest; convert
those helpers into pytest fixtures in conftest (e.g., def
build_fully_populated_config() -> Config marked with `@pytest.fixture` and
similarly for build_minimal_config) and then update tests to accept them as
function arguments instead of importing them, so test_configuration_snapshot.py
uses dependency-injected fixtures rather than direct imports while keeping
existing helper names (build_fully_populated_config, build_minimal_config) for
minimal test changes.
src/telemetry/configuration_snapshot.py (2)

205-225: Hardcoded provider category list will silently miss new llama-stack providers.

The NOTE comment is good, but this will fail silently when llama-stack adds new provider categories — the snapshot simply won't include them. Consider extracting the provider names dynamically from the parsed config's providers dict keys so the snapshot stays in sync automatically, while still applying the same safe per-item masking (only provider_id and provider_type).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/telemetry/configuration_snapshot.py` around lines 205 - 225, The current
ListFieldSpec generator uses a hardcoded tuple of provider category names;
change it to iterate the actual provider keys from the parsed config (e.g., use
the dict keys from the variable holding providers in the snapshot builder or
Config object) so new llama-stack provider categories are picked up
automatically, while keeping the per-item FieldSpec masking of "provider_id" and
"provider_type" with MaskingType.PASSTHROUGH; update the expression that creates
the ListFieldSpec instances (the generator producing
ListFieldSpec(f"providers.{provider_name}",
item_fields=(FieldSpec("provider_id", ...), FieldSpec("provider_type", ...))))
to use dynamic provider_name values from the config's providers dict instead of
the hardcoded tuple.

419-422: Falsy check on db_path treats empty string as not configured.

backend.get("db_path") evaluates to falsy for "", 0, and False — not just None. If a config ever sets db_path to an empty string (misconfiguration but possible), the snapshot would report not_configured rather than configured. Consider using an explicit is not None check for consistency with how mask_value handles SENSITIVE fields (any non-None → configured).

Suggested fix
-        "db_path": CONFIGURED if backend.get("db_path") else NOT_CONFIGURED,
+        "db_path": CONFIGURED if backend.get("db_path") is not None else NOT_CONFIGURED,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/telemetry/configuration_snapshot.py` around lines 419 - 422, The snapshot
currently treats db_path as not configured when backend.get("db_path") is falsy;
update the check in the result dict so db_path is considered CONFIGURED whenever
it is present (non-None) by using an explicit is not None test (e.g., use
CONFIGURED if backend.get("db_path") is not None else NOT_CONFIGURED) to match
the mask_value SENSITIVE handling and avoid treating empty string as not
configured; modify the code that builds result (the dict with keys "type" and
"db_path") accordingly.
tests/unit/telemetry/test_configuration_snapshot.py (1)

32-38: Explicit import from conftest module is fine for helper functions, but consider using fixtures instead.

Importing build_fully_populated_config and build_minimal_config as plain functions from conftest works, but defining them as pytest fixtures in conftest would be more idiomatic and align with pytest conventions. This would also allow automatic dependency injection and reduce boilerplate calls throughout the tests.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/unit/telemetry/test_configuration_snapshot.py` around lines 32 - 38,
The tests explicitly import helper functions build_fully_populated_config and
build_minimal_config from conftest; convert those helpers into pytest fixtures
in conftest (e.g., def build_fully_populated_config() -> Config marked with
`@pytest.fixture` and similarly for build_minimal_config) and then update tests to
accept them as function arguments instead of importing them, so
test_configuration_snapshot.py uses dependency-injected fixtures rather than
direct imports while keeping existing helper names
(build_fully_populated_config, build_minimal_config) for minimal test changes.

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.

1 participant