-
-
Notifications
You must be signed in to change notification settings - Fork 461
POTEL 11 - Move sampling logic into OTel Sampler #3462
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
65 commits
Select commit
Hold shift + click to select a range
305baf5
replace hub with scopes
adinauer 95f5e1b
Add Scopes
adinauer 27f2398
Introduce `IScopes` interface.
adinauer ce3c14f
Replace `IHub` with `IScopes` in core
adinauer ce615f4
Replace `IHub` with `IScopes` in android core
adinauer 22ddc00
Replace `IHub` with `IScopes` in android integrations
adinauer 305c217
Replace `IHub` with `IScopes` in apollo integrations
adinauer da927bc
Replace `IHub` with `IScopes` in okhttp integration
adinauer 8279276
Replace `IHub` with `IScopes` in graphql integration
adinauer 9bfc086
Replace `IHub` with `IScopes` in logging integrations
adinauer b998e50
Replace `IHub` with `IScopes` in more integrations
adinauer 739827a
Replace `IHub` with `IScopes` in OTel integration
adinauer 69f2d63
Replace `IHub` with `IScopes` in Spring 5 / Spring Boot 2 integrations
adinauer 792d482
Replace `IHub` with `IScopes` in Spring 6 / Spring Boot 3 integrations
adinauer 9bcbce6
Replace `IHub` with `IScopes` in samples
adinauer 3f25a4b
Merge branch 'feat/hsm-13-replacements-in-samples' into feat/hubs-sco…
adinauer d6fb40a
gitscopes -> github
adinauer 7752bcc
Replace ThreadLocal with ScopesStorage
adinauer 1e329c5
Move client and throwable to span map to scope
adinauer b0d89ae
Add global scope
adinauer cdd414a
use global scope in Scopes
adinauer 98da9ff
Implement pushScope popScope and withScope for Scopes
adinauer 2d26033
Add pushIsolationScope; add fork methods to ISCope
adinauer bbb6700
Use separate scopes for current, isolation and global scope; rename m…
adinauer c714b21
Allow controlling which scope configureScope uses
adinauer a474402
Combine scopes
adinauer ae93e33
Use new API for CRONS integrations
adinauer b01298b
Add lifecycle helper
adinauer b64e688
Change spring integrations to use new API
adinauer d06fc50
Use new API in servlet integrations
adinauer f0af5c3
Use new API for kotlin coroutines and wrapers for Supplier/Callable
adinauer 2f02001
Discussion TODOs
adinauer bf4a7bf
Fix breadcrumb ordering
adinauer 62cb91a
Mark TODOS with [HSM]
adinauer b1630ea
Add getGlobalScope and forkedRootScopes to IScopes
adinauer 136b9ce
Fix EventProcessor ordering on scopes
adinauer 94d54ef
Reuse code in Scopes
adinauer 017599d
No longer replace global scope
adinauer f4c2b3c
Replace hub occurrences in comments, var names etc.
adinauer 61c9d4a
Implement ScopesTest
adinauer 04f3892
Implement CombinedScopeViewTest
adinauer 840c194
Fix combined contexts
adinauer ab1c3a6
Use combined scopes for cross platform
adinauer 23506c5
Changes according to reviews of previous PRs
adinauer c9b6f8b
more
adinauer 696a809
even more
adinauer 847200d
isEnabled checks client instead of having a property on Scopes
adinauer 8e86d3b
Use SentryOptions.empty
adinauer 06db228
Remove Hub
adinauer 37ab4d0
Use OpenTelemetry for Performance and Scopes propagation
adinauer faef2f8
Promote certain span attributes
adinauer c57b2d3
Merge branch '8.x.x' into feat/potel-1-context-forking-and-basics
adinauer 5b15128
Merge branch '8.x.x' into feat/potel-1-context-forking-and-basics
adinauer 9d48538
Merge branch 'feat/potel-1-context-forking-and-basics' into feat/pote…
adinauer 9b900c7
Use OTel in Sentry API
adinauer b3919bd
Deduplicate SpanInfo extraction
adinauer 36ed84a
Forward Sentry API to Sentry through OTel
adinauer 8f56e3f
Use OTel status for Sentry span API
adinauer 81982bc
POTel Tracing
adinauer fba451c
fix root span detection (remote flag), and scope closing
adinauer c787e91
Inherit OTel span IDs when sending to sentry
adinauer 8c25d81
Fix tracing; parse incoming baggage; add baggage to outgoing
adinauer ffe1bef
Cleanup
adinauer a31fc08
Move sampling logic to OTel Sampler
adinauer ee540fd
Merge branch '8.x.x' into feat/potel-11-otel-sampler
adinauer 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
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
38 changes: 38 additions & 0 deletions
38
...try/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/OtelSamplingUtil.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,38 @@ | ||
| package io.sentry.opentelemetry; | ||
|
|
||
| import io.opentelemetry.api.common.Attributes; | ||
| import io.sentry.TracesSamplingDecision; | ||
| import org.jetbrains.annotations.ApiStatus; | ||
| import org.jetbrains.annotations.NotNull; | ||
| import org.jetbrains.annotations.Nullable; | ||
|
|
||
| @ApiStatus.Internal | ||
| public final class OtelSamplingUtil { | ||
|
|
||
| public static @Nullable TracesSamplingDecision extractSamplingDecisionOrDefault( | ||
| final @NotNull Attributes attributes) { | ||
| final @Nullable TracesSamplingDecision decision = extractSamplingDecision(attributes); | ||
| if (decision != null) { | ||
| return decision; | ||
| } else { | ||
| return new TracesSamplingDecision(false); | ||
| } | ||
| } | ||
|
|
||
| public static @Nullable TracesSamplingDecision extractSamplingDecision( | ||
| final @NotNull Attributes attributes) { | ||
| final @Nullable Boolean sampled = attributes.get(InternalSemanticAttributes.SAMPLED); | ||
| if (sampled != null) { | ||
| final @Nullable Double sampleRate = attributes.get(InternalSemanticAttributes.SAMPLE_RATE); | ||
| final @Nullable Boolean profileSampled = | ||
| attributes.get(InternalSemanticAttributes.PROFILE_SAMPLED); | ||
| final @Nullable Double profileSampleRate = | ||
| attributes.get(InternalSemanticAttributes.PROFILE_SAMPLE_RATE); | ||
|
|
||
| return new TracesSamplingDecision( | ||
| sampled, sampleRate, profileSampled == null ? false : profileSampled, profileSampleRate); | ||
| } else { | ||
| return null; | ||
| } | ||
| } | ||
| } | ||
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 |
|---|---|---|
|
|
@@ -11,17 +11,14 @@ | |
| import io.sentry.Baggage; | ||
| import io.sentry.IScopes; | ||
| import io.sentry.PropagationContext; | ||
| import io.sentry.SamplingContext; | ||
| import io.sentry.ScopesAdapter; | ||
| import io.sentry.Sentry; | ||
| import io.sentry.SentryDate; | ||
| import io.sentry.SentryLevel; | ||
| import io.sentry.SentryLongDate; | ||
| import io.sentry.SentryTraceHeader; | ||
| import io.sentry.SpanId; | ||
| import io.sentry.TracesSampler; | ||
| import io.sentry.TracesSamplingDecision; | ||
| import io.sentry.TransactionContext; | ||
| import io.sentry.protocol.SentryId; | ||
| import org.jetbrains.annotations.NotNull; | ||
| import org.jetbrains.annotations.Nullable; | ||
|
|
@@ -30,15 +27,12 @@ public final class PotelSentrySpanProcessor implements SpanProcessor { | |
| private final @NotNull SentryWeakSpanStorage spanStorage = SentryWeakSpanStorage.getInstance(); | ||
| private final @NotNull IScopes scopes; | ||
|
|
||
| private final @NotNull TracesSampler tracesSampler; | ||
|
|
||
| public PotelSentrySpanProcessor() { | ||
| this(ScopesAdapter.getInstance()); | ||
| } | ||
|
|
||
| PotelSentrySpanProcessor(final @NotNull IScopes scopes) { | ||
| this.scopes = scopes; | ||
| this.tracesSampler = new TracesSampler(scopes.getOptions()); | ||
| } | ||
|
|
||
| @Override | ||
|
|
@@ -55,10 +49,26 @@ public void onStart(final @NotNull Context parentContext, final @NotNull ReadWri | |
|
|
||
| final @Nullable OtelSpanWrapper sentryParentSpan = | ||
| spanStorage.getSentrySpan(otelSpan.getParentSpanContext()); | ||
| @Nullable TracesSamplingDecision samplingDecision = null; | ||
| @NotNull | ||
| TracesSamplingDecision samplingDecision = | ||
| OtelSamplingUtil.extractSamplingDecisionOrDefault(otelSpan.toSpanData().getAttributes()); | ||
| @Nullable Baggage baggage = null; | ||
| otelSpan.setAttribute(IS_REMOTE_PARENT, otelSpan.getParentSpanContext().isRemote()); | ||
| if (sentryParentSpan == null) { | ||
| final @NotNull String traceId = otelSpan.getSpanContext().getTraceId(); | ||
| final @NotNull String spanId = otelSpan.getSpanContext().getSpanId(); | ||
| final @NotNull SpanId sentrySpanId = new SpanId(spanId); | ||
| final @NotNull String parentSpanId = otelSpan.getParentSpanContext().getSpanId(); | ||
| final @Nullable SpanId sentryParentSpanId = | ||
| io.opentelemetry.api.trace.SpanId.isValid(parentSpanId) ? new SpanId(parentSpanId) : null; | ||
|
|
||
| @Nullable | ||
| SentryTraceHeader sentryTraceHeader = parentContext.get(SentryOtelKeys.SENTRY_TRACE_KEY); | ||
| @Nullable Baggage baggageFromContext = parentContext.get(SentryOtelKeys.SENTRY_BAGGAGE_KEY); | ||
| if (sentryTraceHeader != null) { | ||
| baggage = baggageFromContext; | ||
| } | ||
|
|
||
| final @Nullable Boolean baggageMutable = | ||
| otelSpan.getAttribute(InternalSemanticAttributes.BAGGAGE_MUTABLE); | ||
| final @Nullable String baggageString = | ||
|
|
@@ -69,77 +79,20 @@ public void onStart(final @NotNull Context parentContext, final @NotNull ReadWri | |
| baggage.freeze(); | ||
| } | ||
| } | ||
| final @Nullable Boolean sampled = otelSpan.getAttribute(InternalSemanticAttributes.SAMPLED); | ||
| final @Nullable Double sampleRate = | ||
| otelSpan.getAttribute(InternalSemanticAttributes.SAMPLE_RATE); | ||
| final @Nullable Boolean profileSampled = | ||
| otelSpan.getAttribute(InternalSemanticAttributes.PROFILE_SAMPLED); | ||
| final @Nullable Double profileSampleRate = | ||
| otelSpan.getAttribute(InternalSemanticAttributes.PROFILE_SAMPLE_RATE); | ||
| if (sampled != null) { | ||
| // span created by Sentry API | ||
|
|
||
| final @NotNull String traceId = otelSpan.getSpanContext().getTraceId(); | ||
| final @NotNull String spanId = otelSpan.getSpanContext().getSpanId(); | ||
| // TODO [POTEL] parent span id could be invalid | ||
| final @NotNull String parentSpanId = otelSpan.getParentSpanContext().getSpanId(); | ||
|
|
||
| final @NotNull PropagationContext propagationContext = | ||
| new PropagationContext( | ||
| new SentryId(traceId), | ||
| new SpanId(spanId), | ||
| new SpanId(parentSpanId), | ||
| baggage, | ||
| sampled); | ||
|
|
||
| scopes.configureScope( | ||
| scope -> { | ||
| scope.withPropagationContext( | ||
| oldPropagationContext -> { | ||
| scope.setPropagationContext(propagationContext); | ||
| }); | ||
| }); | ||
|
|
||
| // TODO [POTEL] can we use OTel Sampler to let OTel know our sampling decision | ||
| // Sentry not sampled vs OTel not sampled may mean different things for trace propagation | ||
| samplingDecision = | ||
| new TracesSamplingDecision( | ||
| sampled, | ||
| sampleRate, | ||
| profileSampled == null ? false : profileSampled, | ||
| profileSampleRate); | ||
| } else { | ||
| // span not created by Sentry API | ||
|
|
||
| final @NotNull String traceId = otelSpan.getSpanContext().getTraceId(); | ||
| final @NotNull String spanId = otelSpan.getSpanContext().getSpanId(); | ||
| final @NotNull SpanId sentrySpanId = new SpanId(spanId); | ||
|
|
||
| @Nullable | ||
| SentryTraceHeader sentryTraceHeader = parentContext.get(SentryOtelKeys.SENTRY_TRACE_KEY); | ||
| @Nullable Baggage baggageFromContext = parentContext.get(SentryOtelKeys.SENTRY_BAGGAGE_KEY); | ||
| if (sentryTraceHeader != null) { | ||
| baggage = baggageFromContext; | ||
| } | ||
|
|
||
| final @NotNull PropagationContext propagationContext = | ||
| sentryTraceHeader == null | ||
| ? new PropagationContext(new SentryId(traceId), sentrySpanId, null, baggage, null) | ||
| : PropagationContext.fromHeaders(sentryTraceHeader, baggage, sentrySpanId); | ||
|
|
||
| scopes.configureScope( | ||
| scope -> { | ||
| scope.withPropagationContext( | ||
| oldPropagationContext -> { | ||
| scope.setPropagationContext(propagationContext); | ||
| }); | ||
| }); | ||
|
|
||
| final @NotNull TransactionContext transactionContext = | ||
| TransactionContext.fromPropagationContext(propagationContext); | ||
| samplingDecision = tracesSampler.sample(new SamplingContext(transactionContext, null)); | ||
| } | ||
| // TODO [POTEL] what do we use as fallback here? could happen if misconfigured (i.e. sampler | ||
| // not in place) | ||
| final boolean sampled = samplingDecision != null ? samplingDecision.getSampled() : true; | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hmm, should we use
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Hmm not sure |
||
|
|
||
| final @NotNull PropagationContext propagationContext = | ||
| sentryTraceHeader == null | ||
| ? new PropagationContext( | ||
| new SentryId(traceId), sentrySpanId, sentryParentSpanId, baggage, sampled) | ||
| : PropagationContext.fromHeaders(sentryTraceHeader, baggage, sentrySpanId); | ||
|
|
||
| updatePropagationContext(scopes, propagationContext); | ||
| } | ||
|
|
||
| final @NotNull SpanContext spanContext = otelSpan.getSpanContext(); | ||
| final @NotNull SentryDate startTimestamp = | ||
| new SentryLongDate(otelSpan.toSpanData().getStartEpochNanos()); | ||
|
|
@@ -149,6 +102,17 @@ public void onStart(final @NotNull Context parentContext, final @NotNull ReadWri | |
| otelSpan, scopes, startTimestamp, samplingDecision, sentryParentSpan, baggage)); | ||
| } | ||
|
|
||
| private static void updatePropagationContext( | ||
| IScopes scopes, PropagationContext propagationContext) { | ||
| scopes.configureScope( | ||
| scope -> { | ||
| scope.withPropagationContext( | ||
| oldPropagationContext -> { | ||
| scope.setPropagationContext(propagationContext); | ||
| }); | ||
| }); | ||
| } | ||
|
|
||
| @Override | ||
| public boolean isStartRequired() { | ||
| return true; | ||
|
|
||
105 changes: 105 additions & 0 deletions
105
...emetry/sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SentrySampler.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 io.sentry.opentelemetry; | ||
|
|
||
| import io.opentelemetry.api.common.Attributes; | ||
| import io.opentelemetry.api.trace.Span; | ||
| import io.opentelemetry.api.trace.SpanKind; | ||
| import io.opentelemetry.context.Context; | ||
| import io.opentelemetry.sdk.trace.data.LinkData; | ||
| import io.opentelemetry.sdk.trace.samplers.Sampler; | ||
| import io.opentelemetry.sdk.trace.samplers.SamplingResult; | ||
| import io.sentry.Baggage; | ||
| import io.sentry.IScopes; | ||
| import io.sentry.PropagationContext; | ||
| import io.sentry.SamplingContext; | ||
| import io.sentry.ScopesAdapter; | ||
| import io.sentry.SentryTraceHeader; | ||
| import io.sentry.SpanId; | ||
| import io.sentry.TracesSampler; | ||
| import io.sentry.TracesSamplingDecision; | ||
| import io.sentry.TransactionContext; | ||
| import io.sentry.protocol.SentryId; | ||
| import java.util.List; | ||
| import org.jetbrains.annotations.NotNull; | ||
| import org.jetbrains.annotations.Nullable; | ||
|
|
||
| public final class SentrySampler implements Sampler { | ||
|
|
||
| private final @NotNull SentryWeakSpanStorage spanStorage = SentryWeakSpanStorage.getInstance(); | ||
| private final @NotNull TracesSampler tracesSampler; | ||
|
|
||
| public SentrySampler(final @NotNull IScopes scopes) { | ||
| this.tracesSampler = new TracesSampler(scopes.getOptions()); | ||
| } | ||
|
|
||
| public SentrySampler() { | ||
| this(ScopesAdapter.getInstance()); | ||
| } | ||
|
|
||
| @Override | ||
| public SamplingResult shouldSample( | ||
| final @NotNull Context parentContext, | ||
| final @NotNull String traceId, | ||
| final @NotNull String name, | ||
| final @NotNull SpanKind spanKind, | ||
| final @NotNull Attributes attributes, | ||
| final @NotNull List<LinkData> parentLinks) { | ||
| // note: parentLinks seems to usually be empty | ||
| final @Nullable Span parentOtelSpan = Span.fromContextOrNull(parentContext); | ||
| final @Nullable OtelSpanWrapper parentSentrySpan = | ||
| parentOtelSpan != null ? spanStorage.getSentrySpan(parentOtelSpan.getSpanContext()) : null; | ||
|
|
||
| if (parentSentrySpan != null) { | ||
| return copyParentSentryDecision(parentSentrySpan); | ||
| } else { | ||
| final @Nullable TracesSamplingDecision samplingDecision = | ||
| OtelSamplingUtil.extractSamplingDecision(attributes); | ||
| if (samplingDecision != null) { | ||
| return new SentrySamplingResult(samplingDecision); | ||
| } else { | ||
| return handleRootOtelSpan(traceId, parentContext); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| private @NotNull SentrySamplingResult handleRootOtelSpan( | ||
adinauer marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| final @NotNull String traceId, final @NotNull Context parentContext) { | ||
| @Nullable Baggage baggage = null; | ||
| @Nullable | ||
| SentryTraceHeader sentryTraceHeader = parentContext.get(SentryOtelKeys.SENTRY_TRACE_KEY); | ||
| @Nullable Baggage baggageFromContext = parentContext.get(SentryOtelKeys.SENTRY_BAGGAGE_KEY); | ||
| if (sentryTraceHeader != null) { | ||
| baggage = baggageFromContext; | ||
| } | ||
|
|
||
| // there's no way to get the span id here, so we just use a random id for sampling | ||
| SpanId randomSpanId = new SpanId(); | ||
| final @NotNull PropagationContext propagationContext = | ||
| sentryTraceHeader == null | ||
| ? new PropagationContext(new SentryId(traceId), randomSpanId, null, baggage, null) | ||
| : PropagationContext.fromHeaders(sentryTraceHeader, baggage, randomSpanId); | ||
|
|
||
| final @NotNull TransactionContext transactionContext = | ||
| TransactionContext.fromPropagationContext(propagationContext); | ||
| final @NotNull TracesSamplingDecision sentryDecision = | ||
| tracesSampler.sample(new SamplingContext(transactionContext, null)); | ||
| return new SentrySamplingResult(sentryDecision); | ||
| } | ||
|
|
||
| private @NotNull SentrySamplingResult copyParentSentryDecision( | ||
| final @NotNull OtelSpanWrapper parentSentrySpan) { | ||
| final @Nullable TracesSamplingDecision parentSamplingDecision = | ||
| parentSentrySpan.getSamplingDecision(); | ||
| if (parentSamplingDecision != null) { | ||
| return new SentrySamplingResult(parentSamplingDecision); | ||
| } else { | ||
| // this should never happen and only serve to calm the compiler | ||
| // TODO [POTEL] log | ||
| return new SentrySamplingResult(new TracesSamplingDecision(true)); | ||
| } | ||
| } | ||
|
|
||
| @Override | ||
| public String getDescription() { | ||
| return "SentrySampler"; | ||
| } | ||
| } | ||
38 changes: 38 additions & 0 deletions
38
...sentry-opentelemetry-core/src/main/java/io/sentry/opentelemetry/SentrySamplingResult.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,38 @@ | ||
| package io.sentry.opentelemetry; | ||
|
|
||
| import io.opentelemetry.api.common.Attributes; | ||
| import io.opentelemetry.sdk.trace.samplers.SamplingDecision; | ||
| import io.opentelemetry.sdk.trace.samplers.SamplingResult; | ||
| import io.sentry.TracesSamplingDecision; | ||
| import org.jetbrains.annotations.NotNull; | ||
|
|
||
| public final class SentrySamplingResult implements SamplingResult { | ||
| private final TracesSamplingDecision sentryDecision; | ||
|
|
||
| public SentrySamplingResult(final @NotNull TracesSamplingDecision sentryDecision) { | ||
| this.sentryDecision = sentryDecision; | ||
| } | ||
|
|
||
| @Override | ||
| public SamplingDecision getDecision() { | ||
| if (sentryDecision.getSampled()) { | ||
| return SamplingDecision.RECORD_AND_SAMPLE; | ||
| } else { | ||
| return SamplingDecision.RECORD_ONLY; | ||
| } | ||
| } | ||
|
|
||
| @Override | ||
| public Attributes getAttributes() { | ||
| return Attributes.builder() | ||
| .put(InternalSemanticAttributes.SAMPLED, sentryDecision.getSampled()) | ||
| .put(InternalSemanticAttributes.SAMPLE_RATE, sentryDecision.getSampleRate()) | ||
| .put(InternalSemanticAttributes.PROFILE_SAMPLED, sentryDecision.getProfileSampled()) | ||
| .put(InternalSemanticAttributes.PROFILE_SAMPLE_RATE, sentryDecision.getProfileSampleRate()) | ||
| .build(); | ||
| } | ||
|
|
||
| public TracesSamplingDecision getSentryDecision() { | ||
| return sentryDecision; | ||
| } | ||
| } |
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.