From eb906d1d63e98f406b1cf263f589de3eda6517c4 Mon Sep 17 00:00:00 2001 From: Lenz Weber-Tronic Date: Tue, 2 May 2023 13:11:48 +0200 Subject: [PATCH 1/5] metrics experiment --- src/core/ApolloClient.ts | 4 ++ src/core/QueryManager.ts | 40 ++++++----- src/core/types.ts | 9 ++- src/link/metrics-link/index.ts | 117 ++++++++++++++++++++++++++++++++ src/utilities/common/Subject.ts | 19 ++++++ src/utilities/index.ts | 1 + 6 files changed, 171 insertions(+), 19 deletions(-) create mode 100644 src/link/metrics-link/index.ts create mode 100644 src/utilities/common/Subject.ts diff --git a/src/core/ApolloClient.ts b/src/core/ApolloClient.ts index 3bf907d317a..3a4736e4a94 100644 --- a/src/core/ApolloClient.ts +++ b/src/core/ApolloClient.ts @@ -642,4 +642,8 @@ export class ApolloClient implements DataProxy { public setLink(newLink: ApolloLink) { this.link = this.queryManager.link = newLink; } + + public get metrics(){ + return this.queryManager.metrics + } } diff --git a/src/core/QueryManager.ts b/src/core/QueryManager.ts index c2522da30c7..8b3658e2a92 100644 --- a/src/core/QueryManager.ts +++ b/src/core/QueryManager.ts @@ -30,9 +30,10 @@ import { makeUniqueId, isDocumentNode, isNonNullObject, -} from '../utilities'; -import { mergeIncrementalData } from '../utilities/common/incrementalResult'; -import { ApolloError, isApolloError, graphQLResultHasProtocolErrors } from '../errors'; + Subject, +} from "../utilities"; +import { mergeIncrementalData } from "../utilities/common/incrementalResult"; +import { ApolloError, isApolloError, graphQLResultHasProtocolErrors } from "../errors"; import { QueryOptions, WatchQueryOptions, @@ -40,9 +41,9 @@ import { MutationOptions, ErrorPolicy, MutationFetchPolicy, -} from './watchQueryOptions'; -import { ObservableQuery, logMissingFieldErrors } from './ObservableQuery'; -import { NetworkStatus, isNetworkRequestInFlight } from './networkStatus'; +} from "./watchQueryOptions"; +import { ObservableQuery, logMissingFieldErrors } from "./ObservableQuery"; +import { NetworkStatus, isNetworkRequestInFlight } from "./networkStatus"; import { ApolloQueryResult, OperationVariables, @@ -52,16 +53,12 @@ import { InternalRefetchQueriesOptions, InternalRefetchQueriesResult, InternalRefetchQueriesMap, -} from './types'; -import { LocalState } from './LocalState'; + MetricsEvents, +} from "./types"; +import { LocalState } from "./LocalState"; -import { - QueryInfo, - QueryStoreValue, - shouldWriteResult, - CacheWriteBehavior, -} from './QueryInfo'; -import { PROTOCOL_ERRORS_SYMBOL, ApolloErrorOptions } from '../errors'; +import { QueryInfo, QueryStoreValue, shouldWriteResult, CacheWriteBehavior } from "./QueryInfo"; +import { PROTOCOL_ERRORS_SYMBOL, ApolloErrorOptions } from "../errors"; const { hasOwnProperty } = Object.prototype; @@ -86,10 +83,13 @@ interface TransformCacheEntry { type DefaultOptions = import("./ApolloClient").DefaultOptions; +export const traceIdSymbol = Symbol.for("apollo-trace-id"); + export class QueryManager { public cache: ApolloCache; public link: ApolloLink; public defaultOptions: DefaultOptions; + public readonly metrics = new Subject(); public readonly assumeImmutableResults: boolean; public readonly ssrMode: boolean; @@ -735,6 +735,11 @@ export class QueryManager { ).finally(() => this.stopQuery(queryId)); } + private traceIdCounter = 1; + public generateTraceId() { + return String(this.traceIdCounter++); + } + private queryIdCounter = 1; public generateQueryId() { return String(this.queryIdCounter++); @@ -1187,6 +1192,7 @@ export class QueryManager { const query = this.transform(options.query).document; const variables = this.getVariables(query, options.variables) as TVars; const queryInfo = this.getQuery(queryId); + const traceId = this.generateTraceId() const defaults = this.defaultOptions.watchQuery; let { @@ -1204,7 +1210,7 @@ export class QueryManager { errorPolicy, returnPartialData, notifyOnNetworkStatusChange, - context, + context: Object.assign({}, context, { [traceIdSymbol]: traceId }), }); const fromVariables = (variables: TVars) => { @@ -1272,6 +1278,8 @@ export class QueryManager { concast.promise.then(cleanupCancelFn, cleanupCancelFn); + this.metrics.next({ ...normalized, type: 'request', cacheHit: !containsDataFromLink }); + return { concast, fromLink: containsDataFromLink, diff --git a/src/core/types.ts b/src/core/types.ts index df10376341a..18998cf3a72 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -7,13 +7,14 @@ import { QueryInfo } from './QueryInfo'; import { NetworkStatus } from './networkStatus'; import { Resolver } from './LocalState'; import { ObservableQuery } from './ObservableQuery'; -import { QueryOptions } from './watchQueryOptions'; +import { QueryOptions, WatchQueryOptions } from "./watchQueryOptions"; import { Cache } from '../cache'; -import { IsStrictlyAny } from '../utilities'; +import { IsStrictlyAny } from "../utilities"; +import { traceIdSymbol } from "./QueryManager"; export { TypedDocumentNode } from '@graphql-typed-document-node/core'; -export interface DefaultContext extends Record {}; +export interface DefaultContext extends Record {[traceIdSymbol]?: string}; export type QueryListener = (queryInfo: QueryInfo) => void; @@ -194,3 +195,5 @@ export interface Resolvers { [ field: string ]: Resolver; }; } + +export type MetricsEvents = { type: 'request', cacheHit: boolean } & WatchQueryOptions; diff --git a/src/link/metrics-link/index.ts b/src/link/metrics-link/index.ts new file mode 100644 index 00000000000..bcce4525dc7 --- /dev/null +++ b/src/link/metrics-link/index.ts @@ -0,0 +1,117 @@ +import { invariant } from "../../utilities/globals"; + +import { ApolloLink, Operation, FetchResult, NextLink } from "../core"; +import { Observable, Subject, getOperationDefinition, getOperationName } from "../../utilities"; +import { ApolloClient, DefaultContext, MetricsEvents } from "../../core"; +import { Subscription } from "zen-observable-ts"; +import { traceIdSymbol } from "../../core/QueryManager"; + +interface RequestMetrics { + endedAt: number; + responseCode: number; + finishedAs: "success" | "error"; + errors?: readonly unknown[]; +} + +interface BaseMetrics { + operationType: "query" | "mutation" | "subscription"; + operationName: string; + variables: unknown; + traceId: string; + startedAt: number; +} + +export type MetricsLinkEvents = + | ({ type: "cacheHit" } & BaseMetrics) + | ({ type: "request" } & BaseMetrics & RequestMetrics); + +export class MetricsLink extends ApolloLink { + public metrics = new Subject(); + private clientSubscription: Subscription | undefined; + public registerClient(client: ApolloClient) { + if (this.clientSubscription) { + this.clientSubscription.unsubscribe(); + } + client.metrics.subscribe(this.onClientRequest); + } + private runningRequests = new Map>(); + + private onClientRequest = (ev: MetricsEvents) => { + if (ev.type === "request") { + const { query, cacheHit, context, variables } = ev; + const operationType = getOperationDefinition(query)?.operation; + const operationName = getOperationName(query); + const traceId = getTraceId(context); + invariant(operationType && operationName, "invalid operation forwarded"); + const baseMetrics = { + traceId, + operationType, + operationName, + variables, + startedAt: Date.now(), + }; + + if (cacheHit) { + this.metrics.next({ type: "cacheHit", ...baseMetrics }); + } else { + this.runningRequests.set(traceId, baseMetrics); + } + } + }; + + request(operation: Operation, forward?: NextLink) { + invariant(forward, "MetricsLink cannot be the final link."); + + const traceId = getTraceId(operation.getContext()); + + const request = forward(operation); + let initialResponse: FetchResult | undefined; + + return new Observable((observer) => { + request.subscribe({ + next: (result) => { + initialResponse ??= result; + observer.next(result); + }, + error: (error) => { + const { response } = operation.getContext(); + const metrics = this.runningRequests.get(traceId); + if (metrics) { + this.runningRequests.delete(traceId); + this.metrics.next({ + type: "request", + ...metrics, + endedAt: Date.now(), + responseCode: response?.status ?? null, + finishedAs: "error", + errors: [error] + }); + } + observer.error(error); + }, + complete: () => { + const { response } = operation.getContext(); + const metrics = this.runningRequests.get(traceId); + if (metrics) { + this.runningRequests.delete(traceId); + this.metrics.next({ + type: "request", + ...metrics, + endedAt: Date.now(), + responseCode: response.status, + finishedAs: "success", + errors: initialResponse!.errors, + }); + } + observer.complete(); + }, + }); + }); + } +} + +function getTraceId(context: DefaultContext | undefined) { + const traceId = context?.[traceIdSymbol]; + invariant(traceId, "traceId missing in context"); + return traceId; +} diff --git a/src/utilities/common/Subject.ts b/src/utilities/common/Subject.ts new file mode 100644 index 00000000000..da4619d001d --- /dev/null +++ b/src/utilities/common/Subject.ts @@ -0,0 +1,19 @@ +import { Observable, SubscriptionObserver } from "zen-observable-ts"; +import { fixObservableSubclass } from "../observables/subclassing"; + +export class Subject extends Observable { + private subscribers = new Set>(); + constructor() { + super((s) => { + this.subscribers.add(s); + return () => this.subscribers.delete(s); + }); + } + next(value: T) { + this.subscribers.forEach((s) => s.next(value)); + } +} + +// Necessary because the Subject constructor has a different +// signature than the Observable constructor. +fixObservableSubclass(Subject); diff --git a/src/utilities/index.ts b/src/utilities/index.ts index 4f6f4697897..00555754de0 100644 --- a/src/utilities/index.ts +++ b/src/utilities/index.ts @@ -96,5 +96,6 @@ export * from './common/compact'; export * from './common/makeUniqueId'; export * from './common/stringifyForDisplay'; export * from './common/mergeOptions'; +export * from './common/Subject'; export * from './types/IsStrictlyAny'; From 32706a72142a0f024673fc6c4452dac308ba9677 Mon Sep 17 00:00:00 2001 From: Lenz Weber-Tronic Date: Tue, 2 May 2023 16:59:42 +0200 Subject: [PATCH 2/5] make CI happy --- config/bundlesize.ts | 2 +- src/__tests__/__snapshots__/exports.ts.snap | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/config/bundlesize.ts b/config/bundlesize.ts index 31be6ceb8ed..23da66297d0 100644 --- a/config/bundlesize.ts +++ b/config/bundlesize.ts @@ -3,7 +3,7 @@ import { join } from "path"; import { gzipSync } from "zlib"; import bytes from "bytes"; -const gzipBundleByteLengthLimit = bytes("33.02KB"); +const gzipBundleByteLengthLimit = bytes("33.12KB"); const minFile = join("dist", "apollo-client.min.cjs"); const minPath = join(__dirname, "..", minFile); const gzipByteLen = gzipSync(readFileSync(minPath)).byteLength; diff --git a/src/__tests__/__snapshots__/exports.ts.snap b/src/__tests__/__snapshots__/exports.ts.snap index d0603a767d2..94c333b369a 100644 --- a/src/__tests__/__snapshots__/exports.ts.snap +++ b/src/__tests__/__snapshots__/exports.ts.snap @@ -353,6 +353,7 @@ Array [ "DEV", "DeepMerger", "Observable", + "Subject", "addTypenameToDocument", "argumentsObjectFromField", "asyncMap", From deed4ad5a7a4ca9e341fdf9c7aa8e08dee22d1d0 Mon Sep 17 00:00:00 2001 From: Lenz Weber-Tronic Date: Tue, 2 May 2023 17:00:26 +0200 Subject: [PATCH 3/5] changeset --- .changeset/tough-planes-end.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/tough-planes-end.md diff --git a/.changeset/tough-planes-end.md b/.changeset/tough-planes-end.md new file mode 100644 index 00000000000..727529ebc2c --- /dev/null +++ b/.changeset/tough-planes-end.md @@ -0,0 +1,5 @@ +--- +"@apollo/client": patch +--- + +add a MetricsLink From f7192e5e44e6d85d8d52d455b5639039dac539e9 Mon Sep 17 00:00:00 2001 From: Lenz Weber-Tronic Date: Thu, 4 May 2023 12:31:38 +0200 Subject: [PATCH 4/5] add `extractInfo` option to `MetricsLink` --- src/core/types.ts | 3 +- src/link/metrics-link/index.ts | 98 ++++++++++++++++++++++++---------- 2 files changed, 71 insertions(+), 30 deletions(-) diff --git a/src/core/types.ts b/src/core/types.ts index 18998cf3a72..b428d341563 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -196,4 +196,5 @@ export interface Resolvers { }; } -export type MetricsEvents = { type: 'request', cacheHit: boolean } & WatchQueryOptions; +export type MetricsEvents = { type: "request"; cacheHit: boolean } & WatchQueryOptions & + Pick, "context">; diff --git a/src/link/metrics-link/index.ts b/src/link/metrics-link/index.ts index bcce4525dc7..59217ac4dc2 100644 --- a/src/link/metrics-link/index.ts +++ b/src/link/metrics-link/index.ts @@ -8,7 +8,7 @@ import { traceIdSymbol } from "../../core/QueryManager"; interface RequestMetrics { endedAt: number; - responseCode: number; + responseCode: number | null; finishedAs: "success" | "error"; errors?: readonly unknown[]; } @@ -25,17 +25,49 @@ export type MetricsLinkEvents = | ({ type: "cacheHit" } & BaseMetrics) | ({ type: "request" } & BaseMetrics & RequestMetrics); -export class MetricsLink extends ApolloLink { +type ExtractInfoData = + | { + type: "cacheHit"; + context: DefaultContext; + } + | { + type: "request"; + context: DefaultContext; + operation: Operation; + }; + +type ExtractInfo> = ( + data: ExtractInfoData +) => AdditionalMetrics; + +type MetricsLinkOptions> = + keyof AdditionalMetrics extends never + ? { + extractInfo?: ExtractInfo; + } + : { + extractInfo: ExtractInfo; + }; + +export class MetricsLink = {}> extends ApolloLink { public metrics = new Subject(); private clientSubscription: Subscription | undefined; + private runningRequests = new Map>(); + private extractInfo?: ExtractInfo; + + constructor(options: MetricsLinkOptions) { + super(); + if ("extractInfo" in options) { + this.extractInfo = options.extractInfo; + } + } + public registerClient(client: ApolloClient) { if (this.clientSubscription) { this.clientSubscription.unsubscribe(); } client.metrics.subscribe(this.onClientRequest); } - private runningRequests = new Map>(); - private onClientRequest = (ev: MetricsEvents) => { if (ev.type === "request") { const { query, cacheHit, context, variables } = ev; @@ -52,7 +84,11 @@ export class MetricsLink extends ApolloLink { }; if (cacheHit) { - this.metrics.next({ type: "cacheHit", ...baseMetrics }); + const extractedInfo = this.extractInfo?.({ + type: "cacheHit", + context, + }); + this.metrics.next({ ...extractedInfo, type: "cacheHit", ...baseMetrics }); } else { this.runningRequests.set(traceId, baseMetrics); } @@ -67,6 +103,24 @@ export class MetricsLink extends ApolloLink { const request = forward(operation); let initialResponse: FetchResult | undefined; + const emitRequestMetric = (dynamicData: Omit) => { + const metrics = this.runningRequests.get(traceId); + if (!metrics) return; + this.runningRequests.delete(traceId); + const extractedInfo = this.extractInfo?.({ + type: "request", + context: operation.getContext(), + operation, + }); + this.metrics.next({ + ...extractedInfo, + type: "request", + ...metrics, + endedAt: Date.now(), + ...dynamicData, + }); + }; + return new Observable((observer) => { request.subscribe({ next: (result) => { @@ -75,34 +129,20 @@ export class MetricsLink extends ApolloLink { }, error: (error) => { const { response } = operation.getContext(); - const metrics = this.runningRequests.get(traceId); - if (metrics) { - this.runningRequests.delete(traceId); - this.metrics.next({ - type: "request", - ...metrics, - endedAt: Date.now(), - responseCode: response?.status ?? null, - finishedAs: "error", - errors: [error] - }); - } + emitRequestMetric({ + responseCode: response?.status ?? null, + finishedAs: "error", + errors: [error], + }); observer.error(error); }, complete: () => { const { response } = operation.getContext(); - const metrics = this.runningRequests.get(traceId); - if (metrics) { - this.runningRequests.delete(traceId); - this.metrics.next({ - type: "request", - ...metrics, - endedAt: Date.now(), - responseCode: response.status, - finishedAs: "success", - errors: initialResponse!.errors, - }); - } + emitRequestMetric({ + responseCode: response.status, + finishedAs: "success", + errors: initialResponse!.errors, + }); observer.complete(); }, }); From 53863d3a010997fbc00d9b4ac764b9c3ae21317e Mon Sep 17 00:00:00 2001 From: Lenz Weber-Tronic Date: Thu, 4 May 2023 12:33:11 +0200 Subject: [PATCH 5/5] extra check --- src/link/metrics-link/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/link/metrics-link/index.ts b/src/link/metrics-link/index.ts index 59217ac4dc2..358bd64041f 100644 --- a/src/link/metrics-link/index.ts +++ b/src/link/metrics-link/index.ts @@ -57,7 +57,7 @@ export class MetricsLink = {}> constructor(options: MetricsLinkOptions) { super(); - if ("extractInfo" in options) { + if (options && "extractInfo" in options) { this.extractInfo = options.extractInfo; } }