-
Notifications
You must be signed in to change notification settings - Fork 0
fix(observability): close the payload bypasses the telemetry boundary did not cover #137
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
2e454de
docs: open the observability pipeline and payload boundary increment
kl3inIT de426fa
fix(observability): stop the provider libraries from logging prompts
kl3inIT cc7b5ee
fix(observability): strip exception text from every exported span
kl3inIT f17256b
fix(observability): make a failing telemetry sink visible
kl3inIT 8aec3df
docs: record LightRAG's tracing model and the stage coverage it exposes
kl3inIT 04d364c
docs: reconcile secure-graph-rag with the payload boundary changes
kl3inIT eb01be7
docs: close the historical log question for the POC deployment
kl3inIT cecde0f
Merge remote-tracking branch 'origin/main' into fix/observability-pay…
kl3inIT 77aa276
fix(observability): enforce the provider logging boundary at runtime
kl3inIT File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
105 changes: 105 additions & 0 deletions
105
apps/api/src/test/java/com/orgmemory/api/observability/ProviderLoggingBoundaryTests.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,105 @@ | ||
| package com.orgmemory.api.observability; | ||
|
|
||
| import static org.junit.jupiter.api.Assertions.assertEquals; | ||
| import static org.junit.jupiter.api.Assertions.assertTrue; | ||
| import static org.junit.jupiter.api.Assertions.fail; | ||
|
|
||
| import java.io.File; | ||
| import java.io.IOException; | ||
| import java.util.List; | ||
| import java.util.Locale; | ||
| import java.util.Map; | ||
| import java.util.Set; | ||
| import org.junit.jupiter.api.Test; | ||
| import org.springframework.boot.env.YamlPropertySourceLoader; | ||
| import org.springframework.core.env.PropertySource; | ||
| import org.springframework.core.io.ClassPathResource; | ||
| import org.springframework.core.io.FileSystemResource; | ||
|
|
||
| /** | ||
| * The OpenAI and Anthropic client libraries build WARN messages that concatenate | ||
| * the prompt and the response content. {@code spring.ai.chat.observations.log-prompt} | ||
| * does not reach them because it only governs Spring AI observation handlers. | ||
| * | ||
| * <p>Every such call site is guarded by {@code isWarnEnabled()}, so the level decides | ||
| * whether the payload is built at all. This test covers the shipped default only. | ||
| * The default is not the enforcement — environment variables and system properties | ||
| * outrank it — which is what {@code ProviderLoggingBoundaryVerifier} checks at startup | ||
| * against the resolved level. This test stops the default from drifting. | ||
| */ | ||
| class ProviderLoggingBoundaryTests { | ||
|
|
||
| /** Packages whose own logging concatenates OrgMemory prompt or completion text. */ | ||
| private static final List<String> PAYLOAD_LOGGING_PACKAGES = List.of( | ||
| "org.springframework.ai.openai", | ||
| "org.springframework.ai.anthropic"); | ||
|
|
||
| /** Levels at or above ERROR; anything else lets a guarded WARN site build its message. */ | ||
| private static final Set<String> LEVELS_ABOVE_WARN = Set.of("ERROR", "FATAL", "OFF"); | ||
|
|
||
| @Test | ||
| void theBaseConfigurationPinsEveryPayloadLoggingPackageAboveWarn() throws IOException { | ||
| Map<String, Object> properties = load(new ClassPathResource("application.yml")); | ||
|
|
||
| for (String payloadPackage : PAYLOAD_LOGGING_PACKAGES) { | ||
| // The loader returns origin-tracked char sequences rather than plain strings. | ||
| Object level = properties.get("logging.level." + payloadPackage); | ||
| assertEquals( | ||
| "ERROR", | ||
| String.valueOf(level), | ||
| () -> payloadPackage | ||
| + " must be pinned to ERROR in application.yml so its guarded WARN" | ||
| + " sites cannot concatenate the prompt"); | ||
| } | ||
| } | ||
|
|
||
| @Test | ||
| void noProfileLowersAPayloadLoggingPackageBackToWarn() throws IOException { | ||
| File[] profiles = profileConfigurationFiles(); | ||
| assertTrue(profiles.length > 0, "no application-<profile>.yml was scanned, so this test proves nothing"); | ||
|
|
||
| for (File profile : profiles) { | ||
| Map<String, Object> properties = load(new FileSystemResource(profile)); | ||
|
|
||
| properties.forEach((key, value) -> { | ||
| if (!key.startsWith("logging.level.")) { | ||
| return; | ||
| } | ||
| String logger = key.substring("logging.level.".length()); | ||
| // Ancestors and descendants both matter: a parent pinned above the packages | ||
| // relaxes them, and a child such as org.springframework.ai.openai.api overrides | ||
| // the parent pin for everything under it. | ||
| if (PAYLOAD_LOGGING_PACKAGES.stream().noneMatch( | ||
| p -> p.equals(logger) || p.startsWith(logger + ".") || logger.startsWith(p + "."))) { | ||
| return; | ||
| } | ||
| assertTrue( | ||
| LEVELS_ABOVE_WARN.contains(String.valueOf(value).toUpperCase(Locale.ROOT)), | ||
| () -> profile.getName() + " sets " + key + "=" + value | ||
| + ", which re-enables the provider WARN sites that log the prompt"); | ||
| }); | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
| } | ||
| } | ||
|
|
||
| private static Map<String, Object> load(org.springframework.core.io.Resource resource) throws IOException { | ||
| List<PropertySource<?>> sources = new YamlPropertySourceLoader().load(resource.getFilename(), resource); | ||
| if (sources.size() != 1) { | ||
| fail(resource.getFilename() + " must contain exactly one YAML document, found " + sources.size()); | ||
| } | ||
| @SuppressWarnings("unchecked") | ||
| Map<String, Object> properties = (Map<String, Object>) sources.getFirst().getSource(); | ||
| return properties; | ||
| } | ||
|
|
||
| /** | ||
| * Profile files live beside {@code application.yml} in this module's resources. Gradle | ||
| * extracts them to a directory, so listing the parent is enough and keeps the test from | ||
| * hard-coding a profile list that a new profile would silently escape. | ||
| */ | ||
| private static File[] profileConfigurationFiles() throws IOException { | ||
| File resources = new ClassPathResource("application.yml").getFile().getParentFile(); | ||
| File[] profiles = resources.listFiles((directory, name) -> name.startsWith("application-") | ||
| && (name.endsWith(".yml") || name.endsWith(".yaml"))); | ||
| return profiles == null ? new File[0] : profiles; | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
105 changes: 105 additions & 0 deletions
105
...worker/src/test/java/com/orgmemory/worker/observability/ProviderLoggingBoundaryTests.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,105 @@ | ||
| package com.orgmemory.worker.observability; | ||
|
|
||
| import static org.junit.jupiter.api.Assertions.assertEquals; | ||
| import static org.junit.jupiter.api.Assertions.assertTrue; | ||
| import static org.junit.jupiter.api.Assertions.fail; | ||
|
|
||
| import java.io.File; | ||
| import java.io.IOException; | ||
| import java.util.List; | ||
| import java.util.Locale; | ||
| import java.util.Map; | ||
| import java.util.Set; | ||
| import org.junit.jupiter.api.Test; | ||
| import org.springframework.boot.env.YamlPropertySourceLoader; | ||
| import org.springframework.core.env.PropertySource; | ||
| import org.springframework.core.io.ClassPathResource; | ||
| import org.springframework.core.io.FileSystemResource; | ||
|
|
||
| /** | ||
| * The OpenAI and Anthropic client libraries build WARN messages that concatenate | ||
| * the prompt and the response content. {@code spring.ai.chat.observations.log-prompt} | ||
| * does not reach them because it only governs Spring AI observation handlers. | ||
| * | ||
| * <p>Every such call site is guarded by {@code isWarnEnabled()}, so the level decides | ||
| * whether the payload is built at all. The worker runs graph extraction over chunk | ||
| * content, so its prompts carry evidence text directly. This test covers the shipped | ||
| * default only; {@code ProviderLoggingBoundaryVerifier} enforces the resolved level at | ||
| * startup, which is where an environment override is caught. | ||
| */ | ||
| class ProviderLoggingBoundaryTests { | ||
|
|
||
| /** Packages whose own logging concatenates OrgMemory prompt or completion text. */ | ||
| private static final List<String> PAYLOAD_LOGGING_PACKAGES = List.of( | ||
| "org.springframework.ai.openai", | ||
| "org.springframework.ai.anthropic"); | ||
|
|
||
| /** Levels at or above ERROR; anything else lets a guarded WARN site build its message. */ | ||
| private static final Set<String> LEVELS_ABOVE_WARN = Set.of("ERROR", "FATAL", "OFF"); | ||
|
|
||
| @Test | ||
| void theBaseConfigurationPinsEveryPayloadLoggingPackageAboveWarn() throws IOException { | ||
| Map<String, Object> properties = load(new ClassPathResource("application.yml")); | ||
|
|
||
| for (String payloadPackage : PAYLOAD_LOGGING_PACKAGES) { | ||
| // The loader returns origin-tracked char sequences rather than plain strings. | ||
| Object level = properties.get("logging.level." + payloadPackage); | ||
| assertEquals( | ||
| "ERROR", | ||
| String.valueOf(level), | ||
| () -> payloadPackage | ||
| + " must be pinned to ERROR in application.yml so its guarded WARN" | ||
| + " sites cannot concatenate the prompt"); | ||
| } | ||
| } | ||
|
|
||
| @Test | ||
| void noProfileLowersAPayloadLoggingPackageBackToWarn() throws IOException { | ||
| File[] profiles = profileConfigurationFiles(); | ||
| assertTrue(profiles.length > 0, "no application-<profile>.yml was scanned, so this test proves nothing"); | ||
|
|
||
| for (File profile : profiles) { | ||
| Map<String, Object> properties = load(new FileSystemResource(profile)); | ||
|
|
||
| properties.forEach((key, value) -> { | ||
| if (!key.startsWith("logging.level.")) { | ||
| return; | ||
| } | ||
| String logger = key.substring("logging.level.".length()); | ||
| // Ancestors and descendants both matter: a parent pinned above the packages | ||
| // relaxes them, and a child such as org.springframework.ai.openai.api overrides | ||
| // the parent pin for everything under it. | ||
| if (PAYLOAD_LOGGING_PACKAGES.stream().noneMatch( | ||
| p -> p.equals(logger) || p.startsWith(logger + ".") || logger.startsWith(p + "."))) { | ||
| return; | ||
| } | ||
| assertTrue( | ||
| LEVELS_ABOVE_WARN.contains(String.valueOf(value).toUpperCase(Locale.ROOT)), | ||
| () -> profile.getName() + " sets " + key + "=" + value | ||
| + ", which re-enables the provider WARN sites that log the prompt"); | ||
| }); | ||
| } | ||
| } | ||
|
|
||
| private static Map<String, Object> load(org.springframework.core.io.Resource resource) throws IOException { | ||
| List<PropertySource<?>> sources = new YamlPropertySourceLoader().load(resource.getFilename(), resource); | ||
| if (sources.size() != 1) { | ||
| fail(resource.getFilename() + " must contain exactly one YAML document, found " + sources.size()); | ||
| } | ||
| @SuppressWarnings("unchecked") | ||
| Map<String, Object> properties = (Map<String, Object>) sources.getFirst().getSource(); | ||
| return properties; | ||
| } | ||
|
|
||
| /** | ||
| * Profile files live beside {@code application.yml} in this module's resources. Gradle | ||
| * extracts them to a directory, so listing the parent is enough and keeps the test from | ||
| * hard-coding a profile list that a new profile would silently escape. | ||
| */ | ||
| private static File[] profileConfigurationFiles() throws IOException { | ||
| File resources = new ClassPathResource("application.yml").getFile().getParentFile(); | ||
| File[] profiles = resources.listFiles((directory, name) -> name.startsWith("application-") | ||
| && (name.endsWith(".yml") || name.endsWith(".yaml"))); | ||
| return profiles == null ? new File[0] : profiles; | ||
| } | ||
| } |
96 changes: 96 additions & 0 deletions
96
.../src/main/java/com/orgmemory/graphrag/observability/FailureTolerantGraphRagEventSink.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,96 @@ | ||
| package com.orgmemory.graphrag.observability; | ||
|
|
||
| import java.lang.System.Logger; | ||
| import java.util.Objects; | ||
| import java.util.Set; | ||
| import java.util.concurrent.ConcurrentHashMap; | ||
| import java.util.concurrent.atomic.AtomicLong; | ||
| import java.util.concurrent.atomic.AtomicReference; | ||
|
|
||
| /** | ||
| * Absorbs a failing telemetry backend without letting the failure disappear. | ||
| * | ||
| * <p>Producers already treat emission as non-critical and catch whatever the sink | ||
| * throws, which is correct — an observability backend must never decide whether a | ||
| * retrieval or an indexing job succeeds. The cost is that a sink broken since | ||
| * startup looks exactly like a sink with nothing to report. This wrapper keeps the | ||
| * absorption and adds the signal that was missing: a running count, the type of the | ||
| * most recent failure, and one log line the first time each kind of failure appears. | ||
| * | ||
| * <p>Only class names are recorded. A telemetry backend's exception message can | ||
| * quote the request it failed to send, so the message and the stack trace are | ||
| * treated the same way as any other payload and never leave this class. | ||
| * | ||
| * <p>Logging goes through {@link System.Logger} so that | ||
| * {@code components/graph-rag-core} keeps its property of having no runtime | ||
| * dependencies; Spring Boot's JUL bridge routes it into the application log. | ||
| */ | ||
| public final class FailureTolerantGraphRagEventSink implements GraphRagEventSink { | ||
|
|
||
| private static final Logger LOGGER = | ||
| System.getLogger(FailureTolerantGraphRagEventSink.class.getName()); | ||
|
|
||
| /** | ||
| * How many distinct failure types are worth a log line. A backend that produces more | ||
| * than this is malfunctioning in a way one more line will not clarify. | ||
| */ | ||
| private static final int REPORTED_FAILURE_TYPE_LIMIT = 10; | ||
|
|
||
| private final GraphRagEventSink delegate; | ||
| private final AtomicLong swallowedFailures = new AtomicLong(); | ||
| private final AtomicReference<String> lastFailureType = new AtomicReference<>(); | ||
| private final Set<String> reportedFailureTypes = ConcurrentHashMap.newKeySet(); | ||
|
|
||
| FailureTolerantGraphRagEventSink(GraphRagEventSink delegate) { | ||
| this.delegate = Objects.requireNonNull(delegate, "delegate"); | ||
| } | ||
|
|
||
| @Override | ||
| public void emit(GraphRagEvent event) { | ||
| try { | ||
| delegate.emit(event); | ||
| } catch (RuntimeException failure) { | ||
| record(failure); | ||
| } | ||
| } | ||
|
|
||
| /** How many events this sink has dropped since startup. Never resets. */ | ||
| public long swallowedFailureCount() { | ||
| return swallowedFailures.get(); | ||
| } | ||
|
|
||
| /** Class name of the most recent failure, or {@code null} while nothing has failed. */ | ||
| public String lastFailureType() { | ||
| return lastFailureType.get(); | ||
| } | ||
|
|
||
| /** How many log lines this sink has produced. One per distinct failure kind. */ | ||
| int reportedFailureTypeCount() { | ||
| return reportedFailureTypes.size(); | ||
| } | ||
|
|
||
| private void record(RuntimeException failure) { | ||
| swallowedFailures.incrementAndGet(); | ||
| String failureType = failure.getClass().getName(); | ||
| lastFailureType.set(failureType); | ||
| // One line the first time each kind appears. Comparing against the previous kind | ||
| // alone would flood at event rate as soon as a broken backend alternates, which a | ||
| // timeout that retries as a connection failure does immediately. | ||
| if (reportedFailureTypes.size() < REPORTED_FAILURE_TYPE_LIMIT | ||
| && reportedFailureTypes.add(failureType)) { | ||
| LOGGER.log( | ||
| Logger.Level.WARNING, | ||
| "GraphRAG telemetry sink {0} is failing with {1}; events are being dropped." | ||
| + " Message and stack trace are withheld because a telemetry failure" | ||
| + " can quote the event it could not send. Later failures of this kind" | ||
| + " are counted rather than logged.", | ||
| delegate.getClass().getName(), | ||
| failureType); | ||
| } | ||
| } | ||
|
|
||
| @Override | ||
| public String toString() { | ||
| return "FailureTolerantGraphRagEventSink{" + delegate + "}"; | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.