diff --git a/.changeset/smart-ducks-attend.md b/.changeset/smart-ducks-attend.md new file mode 100644 index 0000000000..2a7873069e --- /dev/null +++ b/.changeset/smart-ducks-attend.md @@ -0,0 +1,5 @@ +--- +'@envelop/opentelemetry': minor +--- + +Subscriptions support, expose trace id in the extensions diff --git a/packages/plugins/opentelemetry/package.json b/packages/plugins/opentelemetry/package.json index cb08c26a97..9a4b4a2e15 100644 --- a/packages/plugins/opentelemetry/package.json +++ b/packages/plugins/opentelemetry/package.json @@ -58,6 +58,9 @@ }, "devDependencies": { "@envelop/core": "^5.0.0", + "@graphql-tools/schema": "^10.0.2", + "@opentelemetry/context-async-hooks": "^1.20.0", + "@repeaterjs/repeater": "^3.0.5", "graphql": "16.8.1", "typescript": "5.1.3" }, diff --git a/packages/plugins/opentelemetry/src/has-inline-argument.ts b/packages/plugins/opentelemetry/src/has-inline-argument.ts new file mode 100644 index 0000000000..7c4443b16f --- /dev/null +++ b/packages/plugins/opentelemetry/src/has-inline-argument.ts @@ -0,0 +1,27 @@ +import { BREAK, DocumentNode, visit } from 'graphql'; + +export function hasInlineArgument(doc: DocumentNode) { + let seen = false; + const leave = () => { + seen = true; + return BREAK; + }; + visit(doc, { + StringValue: { + leave, + }, + BooleanValue: { + leave, + }, + FloatValue: { + leave, + }, + EnumValue: { + leave, + }, + IntValue: { + leave, + }, + }); + return seen; +} diff --git a/packages/plugins/opentelemetry/src/index.ts b/packages/plugins/opentelemetry/src/index.ts index d10596c9a2..01a474af61 100644 --- a/packages/plugins/opentelemetry/src/index.ts +++ b/packages/plugins/opentelemetry/src/index.ts @@ -1,13 +1,20 @@ -import { print } from 'graphql'; -import { getDocumentString, isAsyncIterable, OnExecuteHookResult, Plugin } from '@envelop/core'; +import { ExecutionResult, getOperationAST, print } from 'graphql'; +import { + getDocumentString, + isAsyncIterable, + OnExecuteHookResult, + OnSubscribeHookResult, + Plugin, +} from '@envelop/core'; import { useOnResolve } from '@envelop/on-resolve'; -import { SpanAttributes, SpanKind, TracerProvider } from '@opentelemetry/api'; +import { SpanAttributes, SpanKind, SpanStatusCode, TracerProvider } from '@opentelemetry/api'; import * as opentelemetry from '@opentelemetry/api'; import { BasicTracerProvider, ConsoleSpanExporter, SimpleSpanProcessor, } from '@opentelemetry/sdk-trace-base'; +import { hasInlineArgument } from './has-inline-argument.js'; export enum AttributeName { EXECUTION_ERROR = 'graphql.execute.error', @@ -18,6 +25,7 @@ export enum AttributeName { RESOLVER_RESULT_TYPE = 'graphql.resolver.resultType', RESOLVER_ARGS = 'graphql.resolver.args', EXECUTION_OPERATION_NAME = 'graphql.execute.operationName', + EXECUTION_OPERATION_TYPE = 'graphql.execute.operationType', EXECUTION_OPERATION_DOCUMENT = 'graphql.execute.document', EXECUTION_VARIABLES = 'graphql.execute.variables', } @@ -25,9 +33,11 @@ export enum AttributeName { const tracingSpanSymbol = Symbol('OPEN_TELEMETRY_GRAPHQL'); export type TracingOptions = { - resolvers: boolean; - variables: boolean; - result: boolean; + document?: boolean; + resolvers?: boolean; + variables?: boolean; + result?: boolean; + traceIdInResult?: string; }; type PluginContext = { @@ -50,16 +60,16 @@ export const useOpenTelemetry = ( const tracer = tracingProvider.getTracer(serviceName); + const spanByContext = new WeakMap(); + return { onPluginInit({ addPlugin }) { if (options.resolvers) { addPlugin( useOnResolve(({ info, context, args }) => { - if (context && typeof context === 'object' && context[tracingSpanSymbol]) { - const ctx = opentelemetry.trace.setSpan( - opentelemetry.context.active(), - context[tracingSpanSymbol], - ); + const parentSpan = spanByContext.get(context); + if (parentSpan) { + const ctx = opentelemetry.trace.setSpan(opentelemetry.context.active(), parentSpan); const { fieldName, returnType, parentType } = info; const resolverSpan = tracer.startSpan( @@ -92,52 +102,163 @@ export const useOpenTelemetry = ( ); } }, - onExecute({ args, extendContext }) { - const executionSpan = tracer.startSpan(`${args.operationName || 'Anonymous Operation'}`, { + onExecute({ args }) { + const operationAst = getOperationAST(args.document, args.operationName); + if (!operationAst) { + return; + } + const operationType = operationAst.operation; + let isDocumentLoggable: boolean; + if (options.document == null || options.document === true) { + if (options.variables) { + isDocumentLoggable = true; + } else if (!hasInlineArgument(args.document)) { + isDocumentLoggable = true; + } else { + isDocumentLoggable = false; + } + } else { + isDocumentLoggable = false; + } + const operationName = operationAst.name?.value || 'anonymous'; + const executionSpan = tracer.startSpan(`${operationType}.${operationName}`, { kind: spanKind, attributes: { ...spanAdditionalAttributes, - [AttributeName.EXECUTION_OPERATION_NAME]: args.operationName ?? undefined, - [AttributeName.EXECUTION_OPERATION_DOCUMENT]: getDocumentString(args.document, print), + [AttributeName.EXECUTION_OPERATION_NAME]: operationName, + [AttributeName.EXECUTION_OPERATION_TYPE]: operationType, + [AttributeName.EXECUTION_OPERATION_DOCUMENT]: isDocumentLoggable + ? getDocumentString(args.document, print) + : undefined, ...(options.variables ? { [AttributeName.EXECUTION_VARIABLES]: JSON.stringify(args.variableValues ?? {}) } : {}), }, }); + const otelContext = opentelemetry.trace.setSpan( + opentelemetry.context.active(), + executionSpan, + ); + const resultCbs: OnExecuteHookResult = { - onExecuteDone({ result }) { - if (isAsyncIterable(result)) { + onExecuteDone({ result, setResult }) { + if (!isAsyncIterable(result)) { + if (result.data && options.result) { + executionSpan.setAttribute(AttributeName.EXECUTION_RESULT, JSON.stringify(result)); + } + if (options.traceIdInResult) { + setResult(addTraceIdToResult(otelContext, result, options.traceIdInResult)); + } + markError(executionSpan, result); executionSpan.end(); - // eslint-disable-next-line no-console - console.warn( - `Plugin "opentelemetry" encountered an AsyncIterator which is not supported yet, so tracing data is not available for the operation.`, - ); - return; } - if (result.data && options.result) { - executionSpan.setAttribute(AttributeName.EXECUTION_RESULT, JSON.stringify(result)); - } + return { + // handles async iterator + onNext: ({ result, setResult }) => { + if (options.traceIdInResult) { + setResult(addTraceIdToResult(otelContext, result, options.traceIdInResult)); + } + markError(executionSpan, result); + }, + onEnd: () => { + executionSpan.end(); + }, + }; + }, + }; - if (result.errors && result.errors.length > 0) { - executionSpan.recordException({ - name: AttributeName.EXECUTION_ERROR, - message: JSON.stringify(result.errors), - }); - } + if (options.resolvers) { + spanByContext.set(args.contextValue, executionSpan); + } - executionSpan.end(); + return resultCbs; + }, + onSubscribe({ args }) { + const operationAst = getOperationAST(args.document, args.operationName); + if (!operationAst) { + return; + } + const operationType = 'subscription'; + let isDocumentLoggable: boolean; + if (options.variables) { + isDocumentLoggable = true; + } else if (!hasInlineArgument(args.document)) { + isDocumentLoggable = true; + } else { + isDocumentLoggable = false; + } + const operationName = operationAst.name?.value || 'anonymous'; + const subscriptionSpan = tracer.startSpan(`${operationType}.${operationName}`, { + kind: spanKind, + attributes: { + ...spanAdditionalAttributes, + [AttributeName.EXECUTION_OPERATION_NAME]: operationName, + [AttributeName.EXECUTION_OPERATION_TYPE]: operationType, + [AttributeName.EXECUTION_OPERATION_DOCUMENT]: isDocumentLoggable + ? getDocumentString(args.document, print) + : undefined, + ...(options.variables + ? { [AttributeName.EXECUTION_VARIABLES]: JSON.stringify(args.variableValues ?? {}) } + : {}), + }, + }); + + const otelContext = opentelemetry.trace.setSpan( + opentelemetry.context.active(), + subscriptionSpan, + ); + + const resultCbs: OnSubscribeHookResult = { + onSubscribeError: ({ error }) => { + if (error) subscriptionSpan.setStatus({ code: SpanStatusCode.ERROR }); + }, + onSubscribeResult() { + return { + // handles async iterator + onNext: ({ result, setResult }) => { + if (options.traceIdInResult) { + setResult(addTraceIdToResult(otelContext, result, options.traceIdInResult)); + } + markError(subscriptionSpan, result); + }, + onEnd: () => { + subscriptionSpan.end(); + }, + }; }, }; if (options.resolvers) { - extendContext({ - [tracingSpanSymbol]: executionSpan, - }); + spanByContext.set(args.contextValue, subscriptionSpan); } return resultCbs; }, }; }; + +function addTraceIdToResult( + ctx: opentelemetry.Context, + result: ExecutionResult, + traceIdProp: string, +): ExecutionResult { + return { + ...result, + extensions: { + ...result.extensions, + [traceIdProp]: opentelemetry.trace.getSpan(ctx)?.spanContext().traceId, + }, + }; +} + +function markError(executionSpan: opentelemetry.Span, result: ExecutionResult) { + if (result.errors && result.errors.length > 0) { + executionSpan.setStatus({ code: opentelemetry.SpanStatusCode.ERROR }); + executionSpan.recordException({ + name: AttributeName.EXECUTION_ERROR, + message: JSON.stringify(result.errors), + }); + } +} diff --git a/packages/plugins/opentelemetry/test/use-open-telemetry.spec.ts b/packages/plugins/opentelemetry/test/use-open-telemetry.spec.ts index 58fb38344b..1867e5c8ff 100644 --- a/packages/plugins/opentelemetry/test/use-open-telemetry.spec.ts +++ b/packages/plugins/opentelemetry/test/use-open-telemetry.spec.ts @@ -1,11 +1,16 @@ -import { buildSchema } from 'graphql'; +import { buildSchema, GraphQLError } from 'graphql'; import { assertSingleExecutionValue, createTestkit } from '@envelop/testing'; +import { makeExecutableSchema } from '@graphql-tools/schema'; +import * as opentelemetry from '@opentelemetry/api'; +import { SpanStatusCode } from '@opentelemetry/api'; +import { AsyncLocalStorageContextManager } from '@opentelemetry/context-async-hooks'; import { BasicTracerProvider, InMemorySpanExporter, SimpleSpanProcessor, } from '@opentelemetry/sdk-trace-base'; -import { useOpenTelemetry } from '../src/index.js'; +import { Repeater } from '@repeaterjs/repeater'; +import { AttributeName, TracingOptions, useOpenTelemetry } from '../src/index.js'; function createTraceProvider(exporter: InMemorySpanExporter) { const provider = new BasicTracerProvider(); @@ -15,19 +20,51 @@ function createTraceProvider(exporter: InMemorySpanExporter) { return provider; } +const contextManager = new AsyncLocalStorageContextManager().enable(); +opentelemetry.context.setGlobalContextManager(contextManager); + describe('useOpenTelemetry', () => { - const schema = buildSchema(/* GraphQL */ ` - type Query { - ping: String - } - `); - const query = /* GraphQL */ ` - query { - ping - } - `; + const schema = makeExecutableSchema({ + typeDefs: /* GraphQL */ ` + type Query { + ping: String + echo(message: String): String + error: String + context: String + } + + type Subscription { + counter(count: Int!): Int! + } + `, + resolvers: { + Query: { + ping: () => { + return 'pong'; + }, + echo: (_, { message }) => { + return `echo: ${message}`; + }, + error: () => { + throw new GraphQLError('boom'); + }, + }, + Subscription: { + counter: { + subscribe: (_, args) => { + return new Repeater((push, end) => { + for (let i = args.count; i >= 0; i--) { + push({ counter: i }); + } + end(); + }); + }, + }, + }, + }, + }); - const useTestOpenTelemetry = (exporter?: InMemorySpanExporter, options?: any) => + const useTestOpenTelemetry = (exporter?: InMemorySpanExporter, options?: TracingOptions) => useOpenTelemetry( { resolvers: false, @@ -38,6 +75,12 @@ describe('useOpenTelemetry', () => { exporter ? createTraceProvider(exporter) : undefined, ); + const pingQuery = /* GraphQL */ ` + query { + ping + } + `; + it('Should override execute function', async () => { const onExecuteSpy = jest.fn(); const testInstance = createTestkit( @@ -50,7 +93,7 @@ describe('useOpenTelemetry', () => { schema, ); - const result = await testInstance.execute(query); + const result = await testInstance.execute(pingQuery); assertSingleExecutionValue(result); expect(onExecuteSpy).toHaveBeenCalledTimes(1); }); @@ -59,10 +102,10 @@ describe('useOpenTelemetry', () => { const exporter = new InMemorySpanExporter(); const testInstance = createTestkit([useTestOpenTelemetry(exporter)], schema); - await testInstance.execute(query); + await testInstance.execute(pingQuery); const actual = exporter.getFinishedSpans(); expect(actual.length).toBe(1); - expect(actual[0].name).toBe('Anonymous Operation'); + expect(actual[0].name).toBe('query.anonymous'); }); it('Should add resolver span if requested', async () => { @@ -72,10 +115,262 @@ describe('useOpenTelemetry', () => { schema, ); - await testInstance.execute(query); + await testInstance.execute(pingQuery); const actual = exporter.getFinishedSpans(); expect(actual.length).toBe(2); expect(actual[0].name).toBe('Query.ping'); - expect(actual[1].name).toBe('Anonymous Operation'); + expect(actual[1].name).toBe('query.anonymous'); + }); + + it('query should add trace_id to extensions', async () => { + const exporter = new InMemorySpanExporter(); + const testInstance = createTestkit( + [useTestOpenTelemetry(exporter, { traceIdInResult: 'trace_id' })], + schema, + ); + + const result = await testInstance.execute(pingQuery); + assertSingleValue(result); + expect(result.extensions.trace_id).toBeTruthy(); + }); + + it('execute span should use the operation name', async () => { + const exporter = new InMemorySpanExporter(); + const testInstance = createTestkit([useTestOpenTelemetry(exporter)], schema); + + await testInstance.execute(/* GraphQL */ ` + query ping { + ping + } + `); + const actual = exporter.getFinishedSpans(); + expect(actual.length).toBe(1); + expect(actual[0].name).toBe('query.ping'); + }); + + it('execute span should add attributes', async () => { + const exporter = new InMemorySpanExporter(); + const testInstance = createTestkit([useTestOpenTelemetry(exporter)], schema); + + const queryStr = /* GraphQL */ ` + query echo($message: String!) { + echo(message: $message) + } + `; + + const resp = await testInstance.execute(queryStr, { + message: 'hello', + }); + + assertSingleValue(resp); + expect(resp.data).toEqual({ echo: 'echo: hello' }); + + const actual = exporter.getFinishedSpans(); + expect(actual.length).toBe(1); + expect(actual[0].attributes).toEqual({ + [AttributeName.EXECUTION_OPERATION_DOCUMENT]: queryStr, + [AttributeName.EXECUTION_OPERATION_NAME]: 'echo', + [AttributeName.EXECUTION_OPERATION_TYPE]: 'query', + }); + }); + + it('execute span should not add attribute if has inline variable', async () => { + const exporter = new InMemorySpanExporter(); + const testInstance = createTestkit( + [useTestOpenTelemetry(exporter, { variables: false })], + schema, + ); + + const queryStr = /* GraphQL */ ` + query echo { + echo(message: "hello") + } + `; + + const resp = await testInstance.execute(queryStr); + + assertSingleValue(resp); + expect(resp.data).toEqual({ echo: 'echo: hello' }); + + const actual = exporter.getFinishedSpans(); + expect(actual.length).toBe(1); + expect(actual[0].attributes).toEqual({ + [AttributeName.EXECUTION_OPERATION_NAME]: 'echo', + [AttributeName.EXECUTION_OPERATION_TYPE]: 'query', + }); + }); + + it('span should not add document attribute if options false', async () => { + const exporter = new InMemorySpanExporter(); + const testInstance = createTestkit( + [useTestOpenTelemetry(exporter, { document: false })], + schema, + ); + + const queryStr = /* GraphQL */ ` + query echo($message: String!) { + echo(message: $message) + } + `; + + const resp = await testInstance.execute(queryStr, { + message: 'hello', + }); + + assertSingleValue(resp); + expect(resp.data).toEqual({ echo: 'echo: hello' }); + + const actual = exporter.getFinishedSpans(); + expect(actual.length).toBe(1); + expect(actual[0].attributes).toEqual({ + [AttributeName.EXECUTION_OPERATION_NAME]: 'echo', + [AttributeName.EXECUTION_OPERATION_TYPE]: 'query', + }); + }); + + it('span should include error attribute', async () => { + const exporter = new InMemorySpanExporter(); + const testInstance = createTestkit([useTestOpenTelemetry(exporter)], schema); + + const queryStr = /* GraphQL */ ` + query error { + error + } + `; + + const resp = await testInstance.execute(queryStr); + + assertSingleValue(resp); + expect(resp.errors).toBeTruthy(); + + const actual = exporter.getFinishedSpans(); + expect(actual.length).toBe(1); + expect(actual[0].attributes).toEqual({ + [AttributeName.EXECUTION_OPERATION_DOCUMENT]: queryStr, + [AttributeName.EXECUTION_OPERATION_NAME]: 'error', + [AttributeName.EXECUTION_OPERATION_TYPE]: 'query', + }); + expect(actual[0].status.code).toEqual(SpanStatusCode.ERROR); + }); + + it('should add subscription span', async () => { + const exporter = new InMemorySpanExporter(); + const testInstance = createTestkit([useTestOpenTelemetry(exporter)], schema); + + const queryStr = /* GraphQL */ ` + subscription counter($count: Int!) { + counter(count: $count) + } + `; + + const resp = await testInstance.execute(queryStr, { + count: 5, + }); + + assertAsyncIterable(resp); + + let expected = 5; + for await (const value of resp) { + expect(value.data?.counter).toBe(expected); // ensure subscription works + expected--; + } + + await setTimeout$(100); // allow the server some grace to close the graphql.request span after the response finishes + + const actual = exporter.getFinishedSpans(); + expect(actual.length).toBe(1); + expect(actual[0].name).toBe('subscription.counter'); + expect(actual[0].attributes).toEqual({ + [AttributeName.EXECUTION_OPERATION_DOCUMENT]: queryStr, + [AttributeName.EXECUTION_OPERATION_NAME]: 'counter', + [AttributeName.EXECUTION_OPERATION_TYPE]: 'subscription', + }); + }); + + it('should not add subscription span if inline', async () => { + const exporter = new InMemorySpanExporter(); + const testInstance = createTestkit([useTestOpenTelemetry(exporter)], schema); + + const queryStr = /* GraphQL */ ` + subscription { + counter(count: 5) + } + `; + + const resp = await testInstance.execute(queryStr); + + assertAsyncIterable(resp); + + let expected = 5; + for await (const value of resp) { + expect(value.data?.counter).toBe(expected); // ensure subscription works + expected--; + } + + await setTimeout$(100); // allow the server some grace to close the graphql.request span after the response finishes + + const actual = exporter.getFinishedSpans(); + expect(actual.length).toBe(1); + expect(actual[0].name).toBe('subscription.anonymous'); + expect(actual[0].attributes).toEqual({ + [AttributeName.EXECUTION_OPERATION_NAME]: 'anonymous', + [AttributeName.EXECUTION_OPERATION_TYPE]: 'subscription', + }); + }); + + it('subscription should add trace_id to extensions', async () => { + const exporter = new InMemorySpanExporter(); + const testInstance = createTestkit( + [useTestOpenTelemetry(exporter, { traceIdInResult: 'trace_id' })], + schema, + ); + + const queryStr = /* GraphQL */ ` + subscription counter($count: Int!) { + counter(count: $count) + } + `; + + const resp = await testInstance.execute(queryStr, { + count: 5, + }); + + assertAsyncIterable(resp); + + let expected = 5; + for await (const value of resp) { + expect(value.data?.counter).toBe(expected); // ensure subscription works + expect(value.extensions).toHaveProperty('trace_id'); + expected--; + } + + await setTimeout$(100); // allow the server some grace to close the graphql.request span after the response finishes + + const actual = exporter.getFinishedSpans(); + expect(actual.length).toBe(1); + expect(actual[0].name).toBe('subscription.counter'); + expect(actual[0].attributes).toEqual({ + [AttributeName.EXECUTION_OPERATION_DOCUMENT]: queryStr, + [AttributeName.EXECUTION_OPERATION_NAME]: 'counter', + [AttributeName.EXECUTION_OPERATION_TYPE]: 'subscription', + }); }); }); + +function assertSingleValue( + result: any, +): asserts result is { data: any; extensions: any; errors: any[] } { + expect(result).toHaveProperty('data'); + expect(result.data).not.toBeNull(); + expect(result.data).not.toBeUndefined(); +} + +function assertAsyncIterable(value: any): asserts value is AsyncIterable { + expect(value[Symbol.asyncIterator]).toBeInstanceOf(Function); +} + +function setTimeout$(ms: number) { + return new Promise(resolve => { + setTimeout(resolve, ms); + }); +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9d47c35e26..ca1b28b551 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1183,6 +1183,15 @@ importers: '@envelop/core': specifier: ^5.0.0 version: link:../../core/dist + '@graphql-tools/schema': + specifier: ^10.0.2 + version: 10.0.2(graphql@16.6.0) + '@opentelemetry/context-async-hooks': + specifier: ^1.20.0 + version: 1.20.0(@opentelemetry/api@1.0.3) + '@repeaterjs/repeater': + specifier: ^3.0.5 + version: 3.0.5 graphql: specifier: 16.6.0 version: 16.6.0 @@ -6137,6 +6146,15 @@ packages: engines: {node: '>=8.0.0'} dev: true + /@opentelemetry/context-async-hooks@1.20.0(@opentelemetry/api@1.0.3): + resolution: {integrity: sha512-PNecg4zvRF5y5h3luK/hzUEmgZtZ8hbX19TMALj3SVShYS2MrDZG6uT27uLkAwACMfK9BP7/UyXXjND5lkaC2w==} + engines: {node: '>=14'} + peerDependencies: + '@opentelemetry/api': '>=1.0.0 <1.8.0' + dependencies: + '@opentelemetry/api': 1.0.3 + dev: true + /@opentelemetry/core@1.11.0(@opentelemetry/api@1.0.3): resolution: {integrity: sha512-aP1wHSb+YfU0pM63UAkizYPuS4lZxzavHHw5KJfFNN2oWQ79HSm6JR3CzwFKHwKhSzHN8RE9fgP1IdVJ8zmo1w==} engines: {node: '>=14'} @@ -13189,6 +13207,7 @@ packages: /is-accessor-descriptor@0.1.6: resolution: {integrity: sha512-e1BM1qnDbMRG3ll2U9dSK0UMHuWOs3pY3AtcFsmvwPtKL3MML/Q86i+GilLfvqEs4GW+ExB91tQ3Ig9noDIZ+A==} engines: {node: '>=0.10.0'} + deprecated: Please upgrade to v0.1.7 dependencies: kind-of: 3.2.2 dev: false @@ -13196,6 +13215,7 @@ packages: /is-accessor-descriptor@1.0.0: resolution: {integrity: sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ==} engines: {node: '>=0.10.0'} + deprecated: Please upgrade to v1.0.1 dependencies: kind-of: 6.0.3 dev: false @@ -13288,6 +13308,7 @@ packages: /is-data-descriptor@0.1.4: resolution: {integrity: sha512-+w9D5ulSoBNlmw9OHn3U2v51SyoCd0he+bB3xMl62oijhrspxowjU+AIcDY0N3iEJbUEkB15IlMASQsxYigvXg==} engines: {node: '>=0.10.0'} + deprecated: Please upgrade to v0.1.5 dependencies: kind-of: 3.2.2 dev: false @@ -13295,6 +13316,7 @@ packages: /is-data-descriptor@1.0.0: resolution: {integrity: sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ==} engines: {node: '>=0.10.0'} + deprecated: Please upgrade to v1.0.1 dependencies: kind-of: 6.0.3 dev: false