From 4b544b1cd1941587e9c8a663556aa49aaa064b29 Mon Sep 17 00:00:00 2001 From: jjllee Date: Wed, 5 Mar 2025 13:09:41 -0800 Subject: [PATCH 1/3] feat(opentelemetry-sampler-aws-xray): Add Rules Caching and Rules Matching Logic --- .../src/fallback-sampler.ts | 52 ++++ .../src/index.ts | 1 + .../src/remote-sampler.ts | 82 ++++++- .../src/rule-cache.ts | 90 +++++++ .../src/sampling-rule-applier.ts | 157 ++++++++++++ .../src/utils.ts | 104 ++++++++ .../test/fallback-sampler.test.ts | 32 +++ .../test/remote-sampler.test.ts | 113 +++++++-- .../test/rule-cache.test.ts | 147 ++++++++++++ .../test/sampling-rule-applier.test.ts | 227 +++++++++++++++++- .../test/utils.test.ts | 169 +++++++++++++ 11 files changed, 1145 insertions(+), 29 deletions(-) create mode 100644 incubator/opentelemetry-sampler-aws-xray/src/fallback-sampler.ts create mode 100644 incubator/opentelemetry-sampler-aws-xray/src/rule-cache.ts create mode 100644 incubator/opentelemetry-sampler-aws-xray/src/utils.ts create mode 100644 incubator/opentelemetry-sampler-aws-xray/test/fallback-sampler.test.ts create mode 100644 incubator/opentelemetry-sampler-aws-xray/test/rule-cache.test.ts create mode 100644 incubator/opentelemetry-sampler-aws-xray/test/utils.test.ts diff --git a/incubator/opentelemetry-sampler-aws-xray/src/fallback-sampler.ts b/incubator/opentelemetry-sampler-aws-xray/src/fallback-sampler.ts new file mode 100644 index 0000000000..44655915cc --- /dev/null +++ b/incubator/opentelemetry-sampler-aws-xray/src/fallback-sampler.ts @@ -0,0 +1,52 @@ +/* + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Includes work from: +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Attributes, Context, Link, SpanKind } from '@opentelemetry/api'; +import { + Sampler, + SamplingResult, + TraceIdRatioBasedSampler, +} from '@opentelemetry/sdk-trace-base'; + +// FallbackSampler samples 1 req/sec and additional 5% of requests using TraceIdRatioBasedSampler. +export class FallbackSampler implements Sampler { + private fixedRateSampler: TraceIdRatioBasedSampler; + + constructor() { + this.fixedRateSampler = new TraceIdRatioBasedSampler(0.05); + } + + shouldSample( + context: Context, + traceId: string, + spanName: string, + spanKind: SpanKind, + attributes: Attributes, + links: Link[] + ): SamplingResult { + // TODO: implement and use Rate Limiting Sampler + + return this.fixedRateSampler.shouldSample(context, traceId); + } + + public toString(): string { + return 'FallbackSampler{fallback sampling with sampling config of 1 req/sec and 5% of additional requests'; + } +} diff --git a/incubator/opentelemetry-sampler-aws-xray/src/index.ts b/incubator/opentelemetry-sampler-aws-xray/src/index.ts index 7051864c14..d86478d9b1 100644 --- a/incubator/opentelemetry-sampler-aws-xray/src/index.ts +++ b/incubator/opentelemetry-sampler-aws-xray/src/index.ts @@ -13,5 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ + export * from './remote-sampler'; export { AWSXRayRemoteSamplerConfig } from './types'; diff --git a/incubator/opentelemetry-sampler-aws-xray/src/remote-sampler.ts b/incubator/opentelemetry-sampler-aws-xray/src/remote-sampler.ts index 01f4f253bf..edfa2822bb 100644 --- a/incubator/opentelemetry-sampler-aws-xray/src/remote-sampler.ts +++ b/incubator/opentelemetry-sampler-aws-xray/src/remote-sampler.ts @@ -29,15 +29,17 @@ import { import { ParentBasedSampler, Sampler, - SamplingDecision, SamplingResult, } from '@opentelemetry/sdk-trace-base'; import { AWSXRaySamplingClient } from './aws-xray-sampling-client'; +import { FallbackSampler } from './fallback-sampler'; import { AWSXRayRemoteSamplerConfig, GetSamplingRulesResponse, SamplingRuleRecord, } from './types'; +import { RuleCache } from './rule-cache'; + import { SamplingRuleApplier } from './sampling-rule-applier'; // 5 minute default sampling rules polling interval @@ -50,12 +52,14 @@ const DEFAULT_AWS_PROXY_ENDPOINT = 'http://localhost:2000'; export class AWSXRayRemoteSampler implements Sampler { private _root: ParentBasedSampler; private internalXraySampler: _AWSXRayRemoteSampler; + constructor(samplerConfig: AWSXRayRemoteSamplerConfig) { this.internalXraySampler = new _AWSXRayRemoteSampler(samplerConfig); this._root = new ParentBasedSampler({ root: this.internalXraySampler, }); } + public shouldSample( context: Context, traceId: string, @@ -91,8 +95,11 @@ export class AWSXRayRemoteSampler implements Sampler { export class _AWSXRayRemoteSampler implements Sampler { private rulePollingIntervalMillis: number; private awsProxyEndpoint: string; + private ruleCache: RuleCache; + private fallbackSampler: FallbackSampler; private samplerDiag: DiagLogger; private rulePoller: NodeJS.Timeout | undefined; + private clientId: string; private rulePollingJitterMillis: number; private samplingClient: AWSXRaySamplingClient; @@ -117,6 +124,9 @@ export class _AWSXRayRemoteSampler implements Sampler { this.awsProxyEndpoint = samplerConfig.endpoint ? samplerConfig.endpoint : DEFAULT_AWS_PROXY_ENDPOINT; + this.fallbackSampler = new FallbackSampler(); + this.clientId = _AWSXRayRemoteSampler.generateClientId(); + this.ruleCache = new RuleCache(samplerConfig.resource); this.samplingClient = new AWSXRaySamplingClient( this.awsProxyEndpoint, @@ -137,8 +147,44 @@ export class _AWSXRayRemoteSampler implements Sampler { attributes: Attributes, links: Link[] ): SamplingResult { - // Implementation to be added - return { decision: SamplingDecision.NOT_RECORD }; + if (this.ruleCache.isExpired()) { + this.samplerDiag.debug( + 'Rule cache is expired, so using fallback sampling strategy' + ); + return this.fallbackSampler.shouldSample( + context, + traceId, + spanName, + spanKind, + attributes, + links + ); + } + + const matchedRule: SamplingRuleApplier | undefined = + this.ruleCache.getMatchedRule(attributes); + if (matchedRule) { + return matchedRule.shouldSample( + context, + traceId, + spanName, + spanKind, + attributes, + links + ); + } + + this.samplerDiag.debug( + 'Using fallback sampler as no rule match was found. This is likely due to a bug, since default rule should always match' + ); + return this.fallbackSampler.shouldSample( + context, + traceId, + spanName, + spanKind, + attributes, + links + ); } public toString(): string { @@ -180,13 +226,37 @@ export class _AWSXRayRemoteSampler implements Sampler { } } ); - - // TODO: pass samplingRules to rule cache, temporarily logging the samplingRules array - this.samplerDiag.debug('sampling rules: ', samplingRules); + this.ruleCache.updateRules(samplingRules); } else { this.samplerDiag.error( 'SamplingRuleRecords from GetSamplingRules request is not defined' ); } } + + private static generateClientId(): string { + const hexChars: string[] = [ + '0', + '1', + '2', + '3', + '4', + '5', + '6', + '7', + '8', + '9', + 'a', + 'b', + 'c', + 'd', + 'e', + 'f', + ]; + const clientIdArray: string[] = []; + for (let _ = 0; _ < 24; _ += 1) { + clientIdArray.push(hexChars[Math.floor(Math.random() * hexChars.length)]); + } + return clientIdArray.join(''); + } } diff --git a/incubator/opentelemetry-sampler-aws-xray/src/rule-cache.ts b/incubator/opentelemetry-sampler-aws-xray/src/rule-cache.ts new file mode 100644 index 0000000000..2ed3ec3fc1 --- /dev/null +++ b/incubator/opentelemetry-sampler-aws-xray/src/rule-cache.ts @@ -0,0 +1,90 @@ +/* + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Includes work from: +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Attributes } from '@opentelemetry/api'; +import { Resource } from '@opentelemetry/resources'; +import { SamplingRuleApplier } from './sampling-rule-applier'; + +// The cache expires 1 hour after the last refresh time. +const RULE_CACHE_TTL_MILLIS: number = 60 * 60 * 1000; + +export class RuleCache { + private ruleAppliers: SamplingRuleApplier[]; + private lastUpdatedEpochMillis: number; + private samplerResource: Resource; + + constructor(samplerResource: Resource) { + this.ruleAppliers = []; + this.samplerResource = samplerResource; + this.lastUpdatedEpochMillis = Date.now(); + } + + public isExpired(): boolean { + const nowInMillis: number = Date.now(); + return nowInMillis > this.lastUpdatedEpochMillis + RULE_CACHE_TTL_MILLIS; + } + + public getMatchedRule( + attributes: Attributes + ): SamplingRuleApplier | undefined { + return this.ruleAppliers.find( + rule => + rule.matches(attributes, this.samplerResource) || + rule.samplingRule.RuleName === 'Default' + ); + } + + private sortRulesByPriority(): void { + this.ruleAppliers.sort( + (rule1: SamplingRuleApplier, rule2: SamplingRuleApplier): number => { + if (rule1.samplingRule.Priority === rule2.samplingRule.Priority) { + return rule1.samplingRule.RuleName < rule2.samplingRule.RuleName + ? -1 + : 1; + } + return rule1.samplingRule.Priority - rule2.samplingRule.Priority; + } + ); + } + + public updateRules(newRuleAppliers: SamplingRuleApplier[]): void { + const oldRuleAppliersMap: { [key: string]: SamplingRuleApplier } = {}; + + this.ruleAppliers.forEach((rule: SamplingRuleApplier) => { + oldRuleAppliersMap[rule.samplingRule.RuleName] = rule; + }); + + newRuleAppliers.forEach((newRule: SamplingRuleApplier, index: number) => { + const ruleNameToCheck: string = newRule.samplingRule.RuleName; + if (ruleNameToCheck in oldRuleAppliersMap) { + const oldRule: SamplingRuleApplier = + oldRuleAppliersMap[ruleNameToCheck]; + if (newRule.samplingRule.equals(oldRule.samplingRule)) { + newRuleAppliers[index] = oldRule; + } + } + }); + this.ruleAppliers = newRuleAppliers; + + // sort ruleAppliers by priority and update lastUpdatedEpochMillis + this.sortRulesByPriority(); + this.lastUpdatedEpochMillis = Date.now(); + } +} diff --git a/incubator/opentelemetry-sampler-aws-xray/src/sampling-rule-applier.ts b/incubator/opentelemetry-sampler-aws-xray/src/sampling-rule-applier.ts index 92424317ea..435c77b9bf 100644 --- a/incubator/opentelemetry-sampler-aws-xray/src/sampling-rule-applier.ts +++ b/incubator/opentelemetry-sampler-aws-xray/src/sampling-rule-applier.ts @@ -18,12 +18,50 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 +import { + AttributeValue, + Attributes, + Context, + Link, + SpanKind, +} from '@opentelemetry/api'; +import { Resource } from '@opentelemetry/resources'; +import { + SamplingDecision, + SamplingResult, + TraceIdRatioBasedSampler, +} from '@opentelemetry/sdk-trace-base'; +import { + ATTR_CLIENT_ADDRESS, + ATTR_HTTP_REQUEST_METHOD, + ATTR_SERVER_ADDRESS, + ATTR_URL_FULL, + ATTR_URL_PATH, + ATTR_SERVICE_NAME, + SEMRESATTRS_FAAS_ID, +} from '@opentelemetry/semantic-conventions'; +import { + ATTR_HTTP_HOST, + ATTR_HTTP_METHOD, + ATTR_HTTP_URL, + ATTR_HTTP_TARGET, + ATTR_CLOUD_PLATFORM, + ATTR_AWS_ECS_CLUSTER_ARN, + ATTR_AWS_ECS_CONTAINER_ARN, + ATTR_AWS_EKS_CLUSTER_ARN, + CLOUD_PLATFORM_VALUE_AWS_LAMBDA, + ATTR_AWS_LAMBDA_INVOKED_ARN, + ATTR_CLOUD_RESOURCE_ID, +} from './semconv'; import { ISamplingRule, SamplingTargetDocument } from './types'; import { SamplingRule } from './sampling-rule'; import { Statistics } from './statistics'; +import { CLOUD_PLATFORM_MAPPING, attributeMatch, wildcardMatch } from './utils'; export class SamplingRuleApplier { public samplingRule: SamplingRule; + private fixedRateSampler: TraceIdRatioBasedSampler; + private statistics: Statistics; constructor( samplingRule: ISamplingRule, @@ -31,5 +69,124 @@ export class SamplingRuleApplier { target?: SamplingTargetDocument ) { this.samplingRule = new SamplingRule(samplingRule); + + this.fixedRateSampler = new TraceIdRatioBasedSampler( + this.samplingRule.FixedRate + ); + // TODO: Add Reservoir Sampler (Rate Limiting Sampler) + + this.statistics = statistics; + this.statistics.resetStatistics(); + + // TODO: Update Sampling Targets using provided `target` parameter + } + + public matches(attributes: Attributes, resource: Resource): boolean { + let httpTarget: AttributeValue | undefined = undefined; + let httpUrl: AttributeValue | undefined = undefined; + let httpMethod: AttributeValue | undefined = undefined; + let httpHost: AttributeValue | undefined = undefined; + let serviceName: AttributeValue | undefined = undefined; + + if (attributes) { + httpTarget = attributes[ATTR_HTTP_TARGET] ?? attributes[ATTR_URL_PATH]; + httpUrl = attributes[ATTR_HTTP_URL] ?? attributes[ATTR_URL_FULL]; + httpMethod = + attributes[ATTR_HTTP_METHOD] ?? attributes[ATTR_HTTP_REQUEST_METHOD]; + httpHost = + attributes[ATTR_HTTP_HOST] ?? + attributes[ATTR_SERVER_ADDRESS] ?? + attributes[ATTR_CLIENT_ADDRESS]; + } + + let serviceType: AttributeValue | undefined = undefined; + let resourceARN: AttributeValue | undefined = undefined; + + if (resource) { + serviceName = resource.attributes[ATTR_SERVICE_NAME] || ''; + const cloudPlatform: AttributeValue | undefined = + resource.attributes[ATTR_CLOUD_PLATFORM]; + if (typeof cloudPlatform === 'string') { + serviceType = CLOUD_PLATFORM_MAPPING[cloudPlatform]; + } + resourceARN = this.getArn(resource, attributes); + } + + // target may be in url + if (httpTarget === undefined && typeof httpUrl === 'string') { + const schemeEndIndex: number = httpUrl.indexOf('://'); + // For network calls, URL usually has `scheme://host[:port][path][?query][#fragment]` format + // Per spec, url.full is always populated with scheme:// + // If scheme is not present, assume it's bad instrumentation and ignore. + if (schemeEndIndex > -1) { + // urlparse("scheme://netloc/path;parameters?query#fragment") + httpTarget = new URL(httpUrl).pathname; + if (httpTarget === '') httpTarget = '/'; + } + } else if (httpTarget === undefined && httpUrl === undefined) { + // When missing, the URL Path is assumed to be '/' + httpTarget = '/'; + } + + return ( + attributeMatch(attributes, this.samplingRule.Attributes) && + wildcardMatch(this.samplingRule.Host, httpHost) && + wildcardMatch(this.samplingRule.HTTPMethod, httpMethod) && + wildcardMatch(this.samplingRule.ServiceName, serviceName) && + wildcardMatch(this.samplingRule.URLPath, httpTarget) && + wildcardMatch(this.samplingRule.ServiceType, serviceType) && + wildcardMatch(this.samplingRule.ResourceARN, resourceARN) + ); + } + + shouldSample( + context: Context, + traceId: string, + spanName: string, + spanKind: SpanKind, + attributes: Attributes, + links: Link[] + ): SamplingResult { + // TODO: Record Sampling Statistics + + let result: SamplingResult = { decision: SamplingDecision.NOT_RECORD }; + + // TODO: Apply Reservoir Sampling + + if (result.decision === SamplingDecision.NOT_RECORD) { + result = this.fixedRateSampler.shouldSample(context, traceId); + } + + return result; + } + + private getArn( + resource: Resource, + attributes: Attributes + ): AttributeValue | undefined { + let arn: AttributeValue | undefined = + resource.attributes[ATTR_AWS_ECS_CONTAINER_ARN] || + resource.attributes[ATTR_AWS_ECS_CLUSTER_ARN] || + resource.attributes[ATTR_AWS_EKS_CLUSTER_ARN]; + + if ( + arn === undefined && + resource?.attributes[ATTR_CLOUD_PLATFORM] === + CLOUD_PLATFORM_VALUE_AWS_LAMBDA + ) { + arn = this.getLambdaArn(resource, attributes); + } + return arn; + } + + private getLambdaArn( + resource: Resource, + attributes: Attributes + ): AttributeValue | undefined { + const arn: AttributeValue | undefined = + resource?.attributes[ATTR_CLOUD_RESOURCE_ID] || + resource?.attributes[SEMRESATTRS_FAAS_ID] || + attributes[ATTR_AWS_LAMBDA_INVOKED_ARN]; + return arn; } } diff --git a/incubator/opentelemetry-sampler-aws-xray/src/utils.ts b/incubator/opentelemetry-sampler-aws-xray/src/utils.ts new file mode 100644 index 0000000000..ce6438fc41 --- /dev/null +++ b/incubator/opentelemetry-sampler-aws-xray/src/utils.ts @@ -0,0 +1,104 @@ +/* + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Includes work from: +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { AttributeValue, Attributes, diag } from '@opentelemetry/api'; +import { + CLOUD_PLATFORM_VALUE_AWS_EC2, + CLOUD_PLATFORM_VALUE_AWS_ECS, + CLOUD_PLATFORM_VALUE_AWS_EKS, + CLOUD_PLATFORM_VALUE_AWS_ELASTIC_BEANSTALK, + CLOUD_PLATFORM_VALUE_AWS_LAMBDA, +} from './semconv'; + +export const CLOUD_PLATFORM_MAPPING: { [cloudPlatformKey: string]: string } = { + [CLOUD_PLATFORM_VALUE_AWS_LAMBDA]: 'AWS::Lambda::Function', + [CLOUD_PLATFORM_VALUE_AWS_ELASTIC_BEANSTALK]: + 'AWS::ElasticBeanstalk::Environment', + [CLOUD_PLATFORM_VALUE_AWS_EC2]: 'AWS::EC2::Instance', + [CLOUD_PLATFORM_VALUE_AWS_ECS]: 'AWS::ECS::Container', + [CLOUD_PLATFORM_VALUE_AWS_EKS]: 'AWS::EKS::Container', +}; + +// Template function from: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Regular_Expressions#escaping +function escapeRegExp(regExPattern: string): string { + // removed * and ? so they don't get escaped to maintain them as a wildcard match + return regExPattern.replace(/[.+^${}()|[\]\\]/g, '\\$&'); +} + +function convertPatternToRegExp(pattern: string): string { + return escapeRegExp(pattern).replace(/\*/g, '.*').replace(/\?/g, '.'); +} + +export function wildcardMatch( + pattern?: string, + text?: AttributeValue +): boolean { + if (pattern === '*') return true; + if (pattern === undefined || typeof text !== 'string') return false; + if (pattern.length === 0) return text.length === 0; + + const match: RegExpMatchArray | null = text + .toLowerCase() + .match(`^${convertPatternToRegExp(pattern.toLowerCase())}$`); + + if (match === null) { + diag.debug( + `WildcardMatch: no match found for ${text} against pattern ${pattern}` + ); + return false; + } + + return true; +} + +export function attributeMatch( + attributes: Attributes | undefined, + ruleAttributes: { [key: string]: string } | undefined +): boolean { + if (!ruleAttributes || Object.keys(ruleAttributes).length === 0) { + return true; + } + + if ( + attributes === undefined || + Object.keys(attributes).length === 0 || + Object.keys(ruleAttributes).length > Object.keys(attributes).length + ) { + return false; + } + + let matchedCount = 0; + for (const [key, value] of Object.entries(attributes)) { + const foundKey: string | undefined = Object.keys(ruleAttributes).find( + ruleKey => ruleKey === key + ); + + if (foundKey === undefined) { + continue; + } + + if (wildcardMatch(ruleAttributes[foundKey], value)) { + // increment matched count + matchedCount += 1; + } + } + + return matchedCount === Object.keys(ruleAttributes).length; +} diff --git a/incubator/opentelemetry-sampler-aws-xray/test/fallback-sampler.test.ts b/incubator/opentelemetry-sampler-aws-xray/test/fallback-sampler.test.ts new file mode 100644 index 0000000000..79ce1a5fab --- /dev/null +++ b/incubator/opentelemetry-sampler-aws-xray/test/fallback-sampler.test.ts @@ -0,0 +1,32 @@ +/* + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Includes work from: +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { expect } from 'expect'; +import { FallbackSampler } from '../src/fallback-sampler'; + +describe('FallBackSampler', () => { + // TODO: Add tests for Fallback sampler when Rate Limiter is implemented + + it('toString()', () => { + expect(new FallbackSampler().toString()).toEqual( + 'FallbackSampler{fallback sampling with sampling config of 1 req/sec and 5% of additional requests' + ); + }); +}); diff --git a/incubator/opentelemetry-sampler-aws-xray/test/remote-sampler.test.ts b/incubator/opentelemetry-sampler-aws-xray/test/remote-sampler.test.ts index bd45697e94..27fecc0670 100644 --- a/incubator/opentelemetry-sampler-aws-xray/test/remote-sampler.test.ts +++ b/incubator/opentelemetry-sampler-aws-xray/test/remote-sampler.test.ts @@ -18,21 +18,32 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { resourceFromAttributes, emptyResource } from '@opentelemetry/resources'; +import { + resourceFromAttributes, + emptyResource, +} from '@opentelemetry/resources'; +import { context, Span, SpanKind, Tracer, trace } from '@opentelemetry/api'; +import { SamplingDecision } from '@opentelemetry/sdk-trace-base'; +import { NodeTracerProvider } from '@opentelemetry/sdk-trace-node'; import { SEMRESATTRS_CLOUD_PLATFORM, ATTR_SERVICE_NAME, } from '@opentelemetry/semantic-conventions'; import { expect } from 'expect'; +import * as nock from 'nock'; +import * as sinon from 'sinon'; import { _AWSXRayRemoteSampler, AWSXRayRemoteSampler, } from '../src/remote-sampler'; -import * as sinon from 'sinon'; -import * as http from 'http'; +import { AWSXRaySamplingClient } from '../src/aws-xray-sampling-client'; + +const DATA_DIR_SAMPLING_RULES = + __dirname + '/data/test-remote-sampler_sampling-rules-response-sample.json'; +const TEST_URL = 'http://localhost:2000'; describe('AWSXRayRemoteSampler', () => { - let sampler: AWSXRayRemoteSampler | undefined; + let sampler: AWSXRayRemoteSampler; afterEach(() => { if (sampler != null) { @@ -41,15 +52,17 @@ describe('AWSXRayRemoteSampler', () => { }); it('testCreateRemoteSamplerWithEmptyResource', () => { - sampler = new AWSXRayRemoteSampler({ + const sampler: AWSXRayRemoteSampler = new AWSXRayRemoteSampler({ resource: emptyResource(), }); - expect((sampler as any)._root._root.rulePoller).not.toBeFalsy(); - expect((sampler as any)._root._root.rulePollingIntervalMillis).toEqual( + expect(sampler['internalXraySampler']['rulePoller']).not.toBeFalsy(); + expect(sampler['internalXraySampler']['rulePollingIntervalMillis']).toEqual( 300 * 1000 ); - expect((sampler as any)._root._root.samplingClient).not.toBeFalsy(); + expect(sampler['internalXraySampler']['samplingClient']).not.toBeFalsy(); + expect(sampler['internalXraySampler']['ruleCache']).not.toBeFalsy(); + expect(sampler['internalXraySampler']['clientId']).toMatch(/[a-f0-9]{24}/); }); it('testCreateRemoteSamplerWithPopulatedResource', () => { @@ -59,11 +72,16 @@ describe('AWSXRayRemoteSampler', () => { }); sampler = new AWSXRayRemoteSampler({ resource: resource }); - expect((sampler as any)._root._root.rulePoller).not.toBeFalsy(); - expect((sampler as any)._root._root.rulePollingIntervalMillis).toEqual( + expect(sampler['internalXraySampler']['rulePoller']).not.toBeFalsy(); + expect(sampler['internalXraySampler']['rulePollingIntervalMillis']).toEqual( 300 * 1000 ); - expect((sampler as any)._root._root.samplingClient).not.toBeFalsy(); + expect(sampler['internalXraySampler']['samplingClient']).not.toBeFalsy(); + expect(sampler['internalXraySampler']['ruleCache']).not.toBeFalsy(); + expect( + sampler['internalXraySampler']['ruleCache']['samplerResource'].attributes + ).toEqual(resource.attributes); + expect(sampler['internalXraySampler']['clientId']).toMatch(/[a-f0-9]{24}/); }); it('testCreateRemoteSamplerWithAllFieldsPopulated', () => { @@ -77,14 +95,60 @@ describe('AWSXRayRemoteSampler', () => { pollingInterval: 120, // seconds }); - expect((sampler as any)._root._root.rulePoller).not.toBeFalsy(); - expect((sampler as any)._root._root.rulePollingIntervalMillis).toEqual( + expect(sampler['internalXraySampler']['rulePoller']).not.toBeFalsy(); + expect(sampler['internalXraySampler']['rulePollingIntervalMillis']).toEqual( 120 * 1000 ); - expect((sampler as any)._root._root.samplingClient).not.toBeFalsy(); - expect((sampler as any)._root._root.awsProxyEndpoint).toEqual( + expect(sampler['internalXraySampler']['samplingClient']).not.toBeFalsy(); + expect(sampler['internalXraySampler']['ruleCache']).not.toBeFalsy(); + expect( + sampler['internalXraySampler']['ruleCache']['samplerResource'].attributes + ).toEqual(resource.attributes); + expect(sampler['internalXraySampler']['awsProxyEndpoint']).toEqual( 'http://abc.com' ); + expect(sampler['internalXraySampler']['clientId']).toMatch(/[a-f0-9]{24}/); + }); + + it('testUpdateSamplingRulesAndTargetsWithPollersAndShouldSample', done => { + nock(TEST_URL) + .post('/GetSamplingRules') + .reply(200, require(DATA_DIR_SAMPLING_RULES)); + + const resource = resourceFromAttributes({ + [ATTR_SERVICE_NAME]: 'test-service-name', + [SEMRESATTRS_CLOUD_PLATFORM]: 'test-cloud-platform', + }); + + sampler = new AWSXRayRemoteSampler({ + resource: resource, + }); + + setTimeout(() => { + expect( + sampler['internalXraySampler']['ruleCache']['ruleAppliers'][0] + .samplingRule.RuleName + ).toEqual('test'); + expect( + sampler.shouldSample( + context.active(), + '1234', + 'name', + SpanKind.CLIENT, + { abc: '1234' }, + [] + ).decision + ).toEqual(SamplingDecision.NOT_RECORD); + + // TODO: Run more tests after updating Sampling Targets + done(); + }, 50); + }); + + it('generates valid ClientId', () => { + const clientId: string = _AWSXRayRemoteSampler['generateClientId'](); + const match: RegExpMatchArray | null = clientId.match(/[0-9a-z]{24}/g); + expect(match).not.toBeNull(); }); it('toString()', () => { @@ -94,16 +158,21 @@ describe('AWSXRayRemoteSampler', () => { 'AWSXRayRemoteSampler{root=ParentBased{root=_AWSXRayRemoteSampler{awsProxyEndpoint=http://localhost:2000, rulePollingIntervalMillis=300000}, remoteParentSampled=AlwaysOnSampler, remoteParentNotSampled=AlwaysOffSampler, localParentSampled=AlwaysOnSampler, localParentNotSampled=AlwaysOffSampler}' ); }); + + // TODO: Run tests for Reservoir Sampling and Sampling Statistics }); describe('_AWSXRayRemoteSampler', () => { const pollingInterval = 60; let clock: sinon.SinonFakeTimers; - let xrayClientSpy: sinon.SinonSpy; + let fetchSamplingRulesSpy: sinon.SinonSpy; let sampler: _AWSXRayRemoteSampler | undefined; beforeEach(() => { - xrayClientSpy = sinon.spy(http, 'request'); + fetchSamplingRulesSpy = sinon.spy( + AWSXRaySamplingClient.prototype, + 'fetchSamplingRules' + ); clock = sinon.useFakeTimers(); }); @@ -111,19 +180,19 @@ describe('_AWSXRayRemoteSampler', () => { if (sampler != null) { sampler.stopPollers(); } - xrayClientSpy.restore(); + fetchSamplingRulesSpy.restore(); clock.restore(); }); - it('should make a POST request to the /GetSamplingRules endpoint upon initialization', async () => { + it('should invoke fetchSamplingRules() after initialization', async () => { sampler = new _AWSXRayRemoteSampler({ resource: emptyResource(), pollingInterval: pollingInterval, }); - sinon.assert.calledOnce(xrayClientSpy); + sinon.assert.calledOnce(fetchSamplingRulesSpy); }); - it('should make 3 POST requests to the /GetSamplingRules endpoint after 3 intervals have passed', async () => { + it('should invoke fetchSamplingRules() 3 times after initialization and 2 intervals have passed', async () => { sampler = new _AWSXRayRemoteSampler({ resource: emptyResource(), pollingInterval: pollingInterval, @@ -131,6 +200,6 @@ describe('_AWSXRayRemoteSampler', () => { clock.tick(pollingInterval * 1000 + 5000); clock.tick(pollingInterval * 1000 + 5000); - sinon.assert.calledThrice(xrayClientSpy); + sinon.assert.calledThrice(fetchSamplingRulesSpy); }); }); diff --git a/incubator/opentelemetry-sampler-aws-xray/test/rule-cache.test.ts b/incubator/opentelemetry-sampler-aws-xray/test/rule-cache.test.ts new file mode 100644 index 0000000000..ae3cfdc725 --- /dev/null +++ b/incubator/opentelemetry-sampler-aws-xray/test/rule-cache.test.ts @@ -0,0 +1,147 @@ +/* + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Includes work from: +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { emptyResource } from '@opentelemetry/resources'; +import { expect } from 'expect'; +import * as sinon from 'sinon'; +import { RuleCache } from '../src/rule-cache'; +import { SamplingRule } from '../src/sampling-rule'; +import { SamplingRuleApplier } from '../src/sampling-rule-applier'; + +const createRule = ( + name: string, + priority: number, + reservoirSize: number, + fixedRate: number +): SamplingRuleApplier => { + const testSamplingRule = { + RuleName: name, + Priority: priority, + ReservoirSize: reservoirSize, + FixedRate: fixedRate, + ServiceName: '*', + ServiceType: '*', + Host: '*', + HTTPMethod: '*', + URLPath: '*', + ResourceARN: '*', + Version: 1, + }; + return new SamplingRuleApplier(new SamplingRule(testSamplingRule)); +}; + +describe('RuleCache', () => { + it('testCacheUpdatesAndSortsRules', () => { + // Set up default rule in rule cache + const defaultRule = createRule('Default', 10000, 1, 0.05); + const cache = new RuleCache(emptyResource()); + cache.updateRules([defaultRule]); + + // Expect default rule to exist + expect(cache['ruleAppliers'].length).toEqual(1); + + // Set up incoming rules + const rule1 = createRule('low', 200, 0, 0.0); + const rule2 = createRule('abc', 100, 0, 0.0); + const rule3 = createRule('Abc', 100, 0, 0.0); + const rule4 = createRule('ab', 100, 0, 0.0); + const rule5 = createRule('A', 100, 0, 0.0); + const rule6 = createRule('high', 10, 0, 0.0); + const rules = [rule1, rule2, rule3, rule4, rule5, rule6]; + + cache.updateRules(rules); + + // Default rule should be removed because it doesn't exist in the new list + expect(cache['ruleAppliers'].length).toEqual(rules.length); + expect(cache['ruleAppliers'][0].samplingRule.RuleName).toEqual('high'); + expect(cache['ruleAppliers'][1].samplingRule.RuleName).toEqual('A'); + expect(cache['ruleAppliers'][2].samplingRule.RuleName).toEqual('Abc'); + expect(cache['ruleAppliers'][3].samplingRule.RuleName).toEqual('ab'); + expect(cache['ruleAppliers'][4].samplingRule.RuleName).toEqual('abc'); + expect(cache['ruleAppliers'][5].samplingRule.RuleName).toEqual('low'); + }); + + it('testRuleCacheExpirationLogic', () => { + const clock = sinon.useFakeTimers(Date.now()); + + const defaultRule = createRule('Default', 10000, 1, 0.05); + const cache = new RuleCache(emptyResource()); + cache.updateRules([defaultRule]); + + clock.tick(2 * 60 * 60 * 1000); + + expect(cache.isExpired()).toBe(true); + clock.restore(); + }); + + it('testUpdateCacheWithOnlyOneRuleChanged', () => { + // Set up default rule in rule cache + const cache = new RuleCache(emptyResource()); + const rule1 = createRule('rule_1', 1, 0, 0.0); + const rule2 = createRule('rule_2', 10, 0, 0.0); + const rule3 = createRule('rule_3', 100, 0, 0.0); + const ruleAppliers = [rule1, rule2, rule3]; + + cache.updateRules(ruleAppliers); + + const ruleAppliersCopy = cache['ruleAppliers']; + + const newRule3 = createRule('new_rule_3', 5, 0, 0.0); + const newRuleAppliers = [rule1, rule2, newRule3]; + cache.updateRules(newRuleAppliers); + + // Check rule cache is still correct length and has correct rules + expect(cache['ruleAppliers'].length).toEqual(3); + expect(cache['ruleAppliers'][0].samplingRule.RuleName).toEqual('rule_1'); + expect(cache['ruleAppliers'][1].samplingRule.RuleName).toEqual( + 'new_rule_3' + ); + expect(cache['ruleAppliers'][2].samplingRule.RuleName).toEqual('rule_2'); + + // Assert before and after of rule cache + expect(ruleAppliersCopy[0]).toEqual(cache['ruleAppliers'][0]); + expect(ruleAppliersCopy[1]).toEqual(cache['ruleAppliers'][2]); + expect(ruleAppliersCopy[2]).not.toEqual(cache['ruleAppliers'][1]); + }); + + it('testUpdateRulesRemovesOlderRule', () => { + // Set up default rule in rule cache + const cache = new RuleCache(emptyResource()); + expect(cache['ruleAppliers'].length).toEqual(0); + + const rule1 = createRule('first_rule', 200, 0, 0.0); + const rules = [rule1]; + cache.updateRules(rules); + expect(cache['ruleAppliers'].length).toEqual(1); + expect(cache['ruleAppliers'][0].samplingRule.RuleName).toEqual( + 'first_rule' + ); + + const replacement_rule1 = createRule('second_rule', 200, 0, 0.0); + const replacementRules = [replacement_rule1]; + cache.updateRules(replacementRules); + expect(cache['ruleAppliers'].length).toEqual(1); + expect(cache['ruleAppliers'][0].samplingRule.RuleName).toEqual( + 'second_rule' + ); + }); + + // TODO: Add tests for updating Sampling Targets and getting statistics +}); diff --git a/incubator/opentelemetry-sampler-aws-xray/test/sampling-rule-applier.test.ts b/incubator/opentelemetry-sampler-aws-xray/test/sampling-rule-applier.test.ts index 9f08020195..0906a6545a 100644 --- a/incubator/opentelemetry-sampler-aws-xray/test/sampling-rule-applier.test.ts +++ b/incubator/opentelemetry-sampler-aws-xray/test/sampling-rule-applier.test.ts @@ -14,4 +14,229 @@ * limitations under the License. */ -describe('SamplingRuleApplier', () => {}); +// Includes work from: +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { Attributes } from '@opentelemetry/api'; +import { ATTR_SERVICE_NAME } from '@opentelemetry/semantic-conventions'; +import { + resourceFromAttributes, + emptyResource, +} from '@opentelemetry/resources'; +import { + ATTR_AWS_LAMBDA_INVOKED_ARN, + ATTR_HTTP_HOST, + ATTR_HTTP_METHOD, + ATTR_HTTP_TARGET, + ATTR_HTTP_URL, + ATTR_CLOUD_PLATFORM, +} from './../src/semconv'; + +import { expect } from 'expect'; +import { SamplingRule } from '../src/sampling-rule'; +import { SamplingRuleApplier } from '../src/sampling-rule-applier'; + +const DATA_DIR = __dirname + '/data'; + +describe('SamplingRuleApplier', () => { + it('testApplierAttributeMatchingFromXRayResponse', () => { + const sampleData = require(DATA_DIR + + '/get-sampling-rules-response-sample-2.json'); + + const allRules = sampleData['SamplingRuleRecords']; + const defaultRule: SamplingRule = allRules[0]['SamplingRule']; + const samplingRuleApplier = new SamplingRuleApplier(defaultRule); + + const resource = resourceFromAttributes({ + [ATTR_SERVICE_NAME]: 'test_service_name', + [ATTR_CLOUD_PLATFORM]: 'test_cloud_platform', + }); + + const attr: Attributes = { + [ATTR_HTTP_TARGET]: '/target', + [ATTR_HTTP_METHOD]: 'method', + [ATTR_HTTP_URL]: 'url', + [ATTR_HTTP_HOST]: 'host', + ['foo']: 'bar', + ['abc']: '1234', + }; + + expect(samplingRuleApplier.matches(attr, resource)).toEqual(true); + }); + + it('testApplierMatchesWithAllAttributes', () => { + const rule = new SamplingRule({ + Attributes: { abc: '123', def: '4?6', ghi: '*89' }, + FixedRate: 0.11, + HTTPMethod: 'GET', + Host: 'localhost', + Priority: 20, + ReservoirSize: 1, + // Note that ResourceARN is usually only able to be "*" + // See: https://docs.aws.amazon.com/xray/latest/devguide/xray-console-sampling.html#xray-console-sampling-options # noqa: E501 + ResourceARN: 'arn:aws:lambda:us-west-2:123456789012:function:my-function', + RuleARN: 'arn:aws:xray:us-east-1:999999999999:sampling-rule/test', + RuleName: 'test', + ServiceName: 'myServiceName', + ServiceType: 'AWS::Lambda::Function', + URLPath: '/helloworld', + Version: 1, + }); + + const attributes: Attributes = { + [ATTR_HTTP_HOST]: 'localhost', + [ATTR_HTTP_METHOD]: 'GET', + [ATTR_AWS_LAMBDA_INVOKED_ARN]: + 'arn:aws:lambda:us-west-2:123456789012:function:my-function', + [ATTR_HTTP_URL]: 'http://127.0.0.1:5000/helloworld', + ['abc']: '123', + ['def']: '456', + ['ghi']: '789', + }; + + const resource = resourceFromAttributes({ + [ATTR_SERVICE_NAME]: 'myServiceName', + [ATTR_CLOUD_PLATFORM]: 'aws_lambda', + }); + + const ruleApplier = new SamplingRuleApplier(rule); + + expect(ruleApplier.matches(attributes, resource)).toEqual(true); + delete attributes[ATTR_HTTP_URL]; + attributes[ATTR_HTTP_TARGET] = '/helloworld'; + expect(ruleApplier.matches(attributes, resource)).toEqual(true); + }); + it('testApplierWildCardAttributesMatchesSpanAttributes', () => { + const rule = new SamplingRule({ + Attributes: { + attr1: '*', + attr2: '*', + attr3: 'HelloWorld', + attr4: 'Hello*', + attr5: '*World', + attr6: '?ello*', + attr7: 'Hell?W*d', + attr8: '*.World', + attr9: '*.World', + }, + FixedRate: 0.11, + HTTPMethod: '*', + Host: '*', + Priority: 20, + ReservoirSize: 1, + ResourceARN: '*', + RuleARN: 'arn:aws:xray:us-east-1:999999999999:sampling-rule/test', + RuleName: 'test', + ServiceName: '*', + ServiceType: '*', + URLPath: '*', + Version: 1, + }); + const ruleApplier = new SamplingRuleApplier(rule); + + const attributes: Attributes = { + attr1: '', + attr2: 'HelloWorld', + attr3: 'HelloWorld', + attr4: 'HelloWorld', + attr5: 'HelloWorld', + attr6: 'HelloWorld', + attr7: 'HelloWorld', + attr8: 'Hello.World', + attr9: 'Bye.World', + }; + + expect(ruleApplier.matches(attributes, emptyResource())).toEqual(true); + }); + + it('testApplierWildCardAttributesMatchesHttpSpanAttributes', () => { + const ruleApplier = new SamplingRuleApplier( + new SamplingRule({ + Attributes: {}, + FixedRate: 0.11, + HTTPMethod: '*', + Host: '*', + Priority: 20, + ReservoirSize: 1, + ResourceARN: '*', + RuleARN: 'arn:aws:xray:us-east-1:999999999999:sampling-rule/test', + RuleName: 'test', + ServiceName: '*', + ServiceType: '*', + URLPath: '*', + Version: 1, + }) + ); + + const attributes: Attributes = { + [ATTR_HTTP_HOST]: 'localhost', + [ATTR_HTTP_METHOD]: 'GET', + [ATTR_HTTP_URL]: 'http://127.0.0.1:5000/helloworld', + }; + + expect(ruleApplier.matches(attributes, emptyResource())).toEqual(true); + }); + + it('testApplierWildCardAttributesMatchesWithEmptyAttributes', () => { + const ruleApplier = new SamplingRuleApplier( + new SamplingRule({ + Attributes: {}, + FixedRate: 0.11, + HTTPMethod: '*', + Host: '*', + Priority: 20, + ReservoirSize: 1, + ResourceARN: '*', + RuleARN: 'arn:aws:xray:us-east-1:999999999999:sampling-rule/test', + RuleName: 'test', + ServiceName: '*', + ServiceType: '*', + URLPath: '*', + Version: 1, + }) + ); + + const attributes: Attributes = {}; + const resource = resourceFromAttributes({ + [ATTR_SERVICE_NAME]: 'myServiceName', + [ATTR_CLOUD_PLATFORM]: 'aws_ec2', + }); + + expect(ruleApplier.matches(attributes, resource)).toEqual(true); + expect(ruleApplier.matches({}, resource)).toEqual(true); + expect(ruleApplier.matches(attributes, emptyResource())).toEqual(true); + expect(ruleApplier.matches({}, emptyResource())).toEqual(true); + expect(ruleApplier.matches(attributes, emptyResource())).toEqual(true); + expect(ruleApplier.matches({}, emptyResource())).toEqual(true); + }); + + it('testApplierMatchesWithHttpUrlWithHttpTargetUndefined', () => { + const ruleApplier = new SamplingRuleApplier( + new SamplingRule({ + Attributes: {}, + FixedRate: 0.11, + HTTPMethod: '*', + Host: '*', + Priority: 20, + ReservoirSize: 1, + ResourceARN: '*', + RuleARN: 'arn:aws:xray:us-east-1:999999999999:sampling-rule/test', + RuleName: 'test', + ServiceName: '*', + ServiceType: '*', + URLPath: '/somerandompath', + Version: 1, + }) + ); + + const attributes: Attributes = { + [ATTR_HTTP_URL]: 'https://somerandomurl.com/somerandompath', + }; + const resource = emptyResource(); + + expect(ruleApplier.matches(attributes, resource)).toEqual(true); + expect(ruleApplier.matches(attributes, emptyResource())).toEqual(true); + expect(ruleApplier.matches(attributes, emptyResource())).toEqual(true); + }); +}); diff --git a/incubator/opentelemetry-sampler-aws-xray/test/utils.test.ts b/incubator/opentelemetry-sampler-aws-xray/test/utils.test.ts new file mode 100644 index 0000000000..0a25745aa6 --- /dev/null +++ b/incubator/opentelemetry-sampler-aws-xray/test/utils.test.ts @@ -0,0 +1,169 @@ +/* + * Copyright The OpenTelemetry Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Includes work from: +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { expect } from 'expect'; +import * as Utils from '../src/utils'; + +const positiveTests: any = [ + ['*', ''], + ['foo', 'foo'], + ['foo*bar*?', 'foodbaris'], + ['?o?', 'foo'], + ['*oo', 'foo'], + ['foo*', 'foo'], + ['*o?', 'foo'], + ['*', 'boo'], + ['', ''], + ['a', 'a'], + ['*a', 'a'], + ['*a', 'ba'], + ['a*', 'a'], + ['a*', 'ab'], + ['a*a', 'aa'], + ['a*a', 'aba'], + ['a*a*', 'aaaaaaaaaaaaaaaaaaaaaaa'], + [ + 'a*b*a*b*a*b*a*b*a*', + 'akljd9gsdfbkjhaabajkhbbyiaahkjbjhbuykjakjhabkjhbabjhkaabbabbaaakljdfsjklababkjbsdabab', + ], + ['a*na*ha', 'anananahahanahanaha'], + ['***a', 'a'], + ['**a**', 'a'], + ['a**b', 'ab'], + ['*?', 'a'], + ['*??', 'aa'], + ['*?', 'a'], + ['*?*a*', 'ba'], + ['?at', 'bat'], + ['?at', 'cat'], + ['?o?se', 'horse'], + ['?o?se', 'mouse'], + ['*s', 'horses'], + ['J*', 'Jeep'], + ['J*', 'jeep'], + ['*/foo', '/bar/foo'], + ['ja*script', 'javascript'], + ['*', undefined], + ['*', ''], + ['*', 'HelloWorld'], + ['HelloWorld', 'HelloWorld'], + ['Hello*', 'HelloWorld'], + ['*World', 'HelloWorld'], + ['?ello*', 'HelloWorld'], + ['Hell?W*d', 'HelloWorld'], + ['*.World', 'Hello.World'], + ['*.World', 'Bye.World'], +]; + +const negativeTests: any = [ + ['', 'whatever'], + ['/', 'target'], + ['/', '/target'], + ['foo', 'bar'], + ['f?o', 'boo'], + ['f??', 'boo'], + ['fo*', 'boo'], + ['f?*', 'boo'], + ['abcd', 'abc'], + ['??', 'a'], + ['??', 'a'], + ['*?*a', 'a'], + ['a*na*ha', 'anananahahanahana'], + ['*s', 'horse'], +]; + +describe('SamplingUtils', () => { + describe('testWildcardMatch', () => { + it('withOnlyWildcard', () => { + expect(Utils.wildcardMatch('*', undefined)).toEqual(true); + }); + it('withUndefinedPattern', () => { + expect(Utils.wildcardMatch(undefined, '')).toEqual(false); + }); + it('withEmptyPatternAndText', () => { + expect(Utils.wildcardMatch('', '')).toEqual(true); + }); + it('withRegexSuccess', () => { + positiveTests.forEach((test: any) => { + expect(Utils.wildcardMatch(test[0], test[1])).toEqual(true); + }); + }); + it('withRegexFailure', () => { + negativeTests.forEach((test: any) => { + expect(Utils.wildcardMatch(test[0], test[1])).toEqual(false); + }); + }); + }); + + describe('testAttributeMatch', () => { + it('testUndefinedAttributes', () => { + const ruleAttributes = { string: 'string', string2: 'string2' }; + expect(Utils.attributeMatch(undefined, ruleAttributes)).toEqual(false); + expect(Utils.attributeMatch({}, ruleAttributes)).toEqual(false); + expect( + Utils.attributeMatch({ string: 'string' }, ruleAttributes) + ).toEqual(false); + }); + it('testUndefinedRuleAttributes', () => { + const attr = { + number: 1, + string: 'string', + undefined: undefined, + boolean: true, + }; + + expect(Utils.attributeMatch(attr, undefined)).toEqual(true); + }); + it('testSuccessfulMatch', () => { + const attr = { language: 'english' }; + const ruleAttribute = { language: 'en*sh' }; + + expect(Utils.attributeMatch(attr, ruleAttribute)).toEqual(true); + }); + it('testFailedMatch', () => { + const attr = { language: 'french' }; + const ruleAttribute = { language: 'en*sh' }; + + expect(Utils.attributeMatch(attr, ruleAttribute)).toEqual(false); + }); + it('testExtraAttributesSuccess', () => { + const attr = { + number: 1, + string: 'string', + undefined: undefined, + boolean: true, + }; + const ruleAttribute = { string: 'string' }; + + expect(Utils.attributeMatch(attr, ruleAttribute)).toEqual(true); + }); + it('testExtraAttributesSuccess', () => { + const attr = { + number: 1, + string: 'string', + undefined: undefined, + boolean: true, + }; + const ruleAttribute = { string: 'string', number: '1' }; + + expect(Utils.attributeMatch(attr, ruleAttribute)).toEqual(false); + }); + }); +}); From a79cfd5c0210e9ccb08a8c0da26f741e76a824e9 Mon Sep 17 00:00:00 2001 From: jjllee Date: Tue, 27 May 2025 23:48:46 -0700 Subject: [PATCH 2/3] address comments --- .../src/fallback-sampler.ts | 2 +- .../src/rule-cache.ts | 12 +++++++----- .../src/sampling-rule-applier.ts | 14 ++++++++++---- .../test/fallback-sampler.test.ts | 2 +- 4 files changed, 19 insertions(+), 11 deletions(-) diff --git a/incubator/opentelemetry-sampler-aws-xray/src/fallback-sampler.ts b/incubator/opentelemetry-sampler-aws-xray/src/fallback-sampler.ts index 44655915cc..d61dd8fb60 100644 --- a/incubator/opentelemetry-sampler-aws-xray/src/fallback-sampler.ts +++ b/incubator/opentelemetry-sampler-aws-xray/src/fallback-sampler.ts @@ -47,6 +47,6 @@ export class FallbackSampler implements Sampler { } public toString(): string { - return 'FallbackSampler{fallback sampling with sampling config of 1 req/sec and 5% of additional requests'; + return 'FallbackSampler{fallback sampling with sampling config of 1 req/sec and 5% of additional requests}'; } } diff --git a/incubator/opentelemetry-sampler-aws-xray/src/rule-cache.ts b/incubator/opentelemetry-sampler-aws-xray/src/rule-cache.ts index 2ed3ec3fc1..cf762d454c 100644 --- a/incubator/opentelemetry-sampler-aws-xray/src/rule-cache.ts +++ b/incubator/opentelemetry-sampler-aws-xray/src/rule-cache.ts @@ -44,6 +44,9 @@ export class RuleCache { public getMatchedRule( attributes: Attributes ): SamplingRuleApplier | undefined { + // `this.ruleAppliers` should be sorted by priority, so `find()` is used here + // to determine the first highest priority rule that is matched. The last rule + // in the list should be the 'Default' rule with hardcoded priority of 10000. return this.ruleAppliers.find( rule => rule.matches(attributes, this.samplerResource) || @@ -65,17 +68,16 @@ export class RuleCache { } public updateRules(newRuleAppliers: SamplingRuleApplier[]): void { - const oldRuleAppliersMap: { [key: string]: SamplingRuleApplier } = {}; + const oldRuleAppliersMap = new Map(); this.ruleAppliers.forEach((rule: SamplingRuleApplier) => { - oldRuleAppliersMap[rule.samplingRule.RuleName] = rule; + oldRuleAppliersMap.set(rule.samplingRule.RuleName, rule); }); newRuleAppliers.forEach((newRule: SamplingRuleApplier, index: number) => { const ruleNameToCheck: string = newRule.samplingRule.RuleName; - if (ruleNameToCheck in oldRuleAppliersMap) { - const oldRule: SamplingRuleApplier = - oldRuleAppliersMap[ruleNameToCheck]; + const oldRule = oldRuleAppliersMap.get(ruleNameToCheck); + if (oldRule) { if (newRule.samplingRule.equals(oldRule.samplingRule)) { newRuleAppliers[index] = oldRule; } diff --git a/incubator/opentelemetry-sampler-aws-xray/src/sampling-rule-applier.ts b/incubator/opentelemetry-sampler-aws-xray/src/sampling-rule-applier.ts index 435c77b9bf..a148f2ca98 100644 --- a/incubator/opentelemetry-sampler-aws-xray/src/sampling-rule-applier.ts +++ b/incubator/opentelemetry-sampler-aws-xray/src/sampling-rule-applier.ts @@ -24,6 +24,7 @@ import { Context, Link, SpanKind, + diag, } from '@opentelemetry/api'; import { Resource } from '@opentelemetry/resources'; import { @@ -119,11 +120,16 @@ export class SamplingRuleApplier { // Per spec, url.full is always populated with scheme:// // If scheme is not present, assume it's bad instrumentation and ignore. if (schemeEndIndex > -1) { - // urlparse("scheme://netloc/path;parameters?query#fragment") - httpTarget = new URL(httpUrl).pathname; - if (httpTarget === '') httpTarget = '/'; + try { + httpTarget = new URL(httpUrl).pathname; + if (httpTarget === '') httpTarget = '/'; + } catch (e: unknown) { + diag.debug(`Unable to create URL object from url: ${httpUrl}`, e); + } } - } else if (httpTarget === undefined && httpUrl === undefined) { + } + + if (httpTarget === undefined) { // When missing, the URL Path is assumed to be '/' httpTarget = '/'; } diff --git a/incubator/opentelemetry-sampler-aws-xray/test/fallback-sampler.test.ts b/incubator/opentelemetry-sampler-aws-xray/test/fallback-sampler.test.ts index 79ce1a5fab..06ce23cb55 100644 --- a/incubator/opentelemetry-sampler-aws-xray/test/fallback-sampler.test.ts +++ b/incubator/opentelemetry-sampler-aws-xray/test/fallback-sampler.test.ts @@ -26,7 +26,7 @@ describe('FallBackSampler', () => { it('toString()', () => { expect(new FallbackSampler().toString()).toEqual( - 'FallbackSampler{fallback sampling with sampling config of 1 req/sec and 5% of additional requests' + 'FallbackSampler{fallback sampling with sampling config of 1 req/sec and 5% of additional requests}' ); }); }); From fbde9834432f2cbfd28d3e473c0a3e8e8fba6c9f Mon Sep 17 00:00:00 2001 From: jjllee Date: Thu, 12 Jun 2025 11:04:52 -0700 Subject: [PATCH 3/3] address comments - error handling for rule matching and TODO comment --- .../src/remote-sampler.ts | 28 ++++++++++++------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/incubator/opentelemetry-sampler-aws-xray/src/remote-sampler.ts b/incubator/opentelemetry-sampler-aws-xray/src/remote-sampler.ts index edfa2822bb..5cc080ee90 100644 --- a/incubator/opentelemetry-sampler-aws-xray/src/remote-sampler.ts +++ b/incubator/opentelemetry-sampler-aws-xray/src/remote-sampler.ts @@ -125,6 +125,7 @@ export class _AWSXRayRemoteSampler implements Sampler { ? samplerConfig.endpoint : DEFAULT_AWS_PROXY_ENDPOINT; this.fallbackSampler = new FallbackSampler(); + // TODO: Use clientId for retrieving Sampling Targets this.clientId = _AWSXRayRemoteSampler.generateClientId(); this.ruleCache = new RuleCache(samplerConfig.resource); @@ -161,16 +162,23 @@ export class _AWSXRayRemoteSampler implements Sampler { ); } - const matchedRule: SamplingRuleApplier | undefined = - this.ruleCache.getMatchedRule(attributes); - if (matchedRule) { - return matchedRule.shouldSample( - context, - traceId, - spanName, - spanKind, - attributes, - links + try { + const matchedRule: SamplingRuleApplier | undefined = + this.ruleCache.getMatchedRule(attributes); + if (matchedRule) { + return matchedRule.shouldSample( + context, + traceId, + spanName, + spanKind, + attributes, + links + ); + } + } catch (e: unknown) { + this.samplerDiag.debug( + 'Unexpected error occurred when trying to match or applying a sampling rule', + e ); }