Merge pull request #9166 from backstage/analytics/user-identity-3

[Analytics] Convention for tying events to known users
This commit is contained in:
Eric Peterson
2022-01-31 19:22:13 +01:00
committed by GitHub
13 changed files with 641 additions and 37 deletions
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/plugin-analytics-module-ga': patch
---
Added the ability to capture and set user IDs from Backstage's `identityApi`. For full instructions on how to
set this up, see [the User ID section of its README](https://github.com/backstage/backstage/tree/master/plugins/analytics-module-ga#user-ids)
+1
View File
@@ -223,6 +223,7 @@ productional
Protobuf
proxying
Proxying
pseudonymized
pubsub
pygments
pymdownx
+57
View File
@@ -141,6 +141,63 @@ By convention, such packages should be named
`@backstage/analytics-module-[name]`, and any configuration should be keyed
under `app.analytics.[name]`.
### Handling User Identity
If the analytics platform you are integrating with has a first-class concept of
user identity, you can (optionally) choose to support this by the following this
convention:
- Allow your implementation to be instantiated with the `identityApi` as one of
its options in a `fromConfig` static method.
- Use the `userEntityRef` resolved by `identityApi`'s `getBackstageIdentity()`
method as the basis for the user ID you send to your analytics platform.
For example:
```typescript
import {
AnalyticsApi,
analyticsApiRef,
AnyApiFactory,
configApiRef,
createApiFactory,
identityApiRef,
IdentityApi,
} from '@backstage/core-plugin-api';
// Implementation that optionally initializes with a userId.
class AcmeAnalytics implements AnalyticsApi {
private constructor(accountId: number, identityApi?: IdentityApi) {
if (identityApi) {
identityApi.getBackstageIdentity().then(identity => {
AcmeAnalytics.init(accountId, {
userId: identity.userEntityRef,
});
});
} else {
AcmeAnalytics.init(accountId);
}
}
static fromConfig(config, options) {
const accountId = config.getString('app.analytics.acme.id');
return new AcmeAnalytics(accountId, options.identityApi);
}
}
// Your implementation should be instantiated like this:
export const apis: AnyApiFactory[] = [
createApiFactory({
api: analyticsApiRef,
deps: { configApi: configApiRef, identityApi: identityApiRef },
factory: ({ configApi, identityApi }) =>
AcmeAnalytics.fromConfig(configApi, {
identityApi,
}),
}),
];
```
## Capturing Events
To instrument an event in a component, start by retrieving an analytics tracker
+68 -3
View File
@@ -14,15 +14,22 @@ This plugin contains no other functionality.
```tsx
// packages/app/src/apis.ts
import { analyticsApiRef, configApiRef } from '@backstage/core-plugin-api';
import {
analyticsApiRef,
configApiRef,
identityApiRef,
} from '@backstage/core-plugin-api';
import { GoogleAnalytics } from '@backstage/plugin-analytics-module-ga';
export const apis: AnyApiFactory[] = [
// Instantiate and register the GA Analytics API Implementation.
createApiFactory({
api: analyticsApiRef,
deps: { configApi: configApiRef },
factory: ({ configApi }) => GoogleAnalytics.fromConfig(configApi),
deps: { configApi: configApiRef, identityApi: identityApiRef },
factory: ({ configApi, identityApi }) =>
GoogleAnalytics.fromConfig(configApi, {
identityApi,
}),
}),
];
```
@@ -92,6 +99,63 @@ app:
key: someEventContextAttr
```
### User IDs
This plugin supports accurately deriving user-oriented metrics (like monthly
active users) using Google Analytics' [user ID views][ga-user-id-view]. To
enable this...
1. Be sure you've gone through the process of setting up a user ID view in your
Backstage instance's Google Analytics property (see docs linked above).
2. Make sure you instantiate `GoogleAnalytics` with an `identityApi` instance
passed to it, as shown in the installation section above.
3. Set `app.analytics.ga.identity` to either `required` or `optional` in your
`app.config.yaml`, like this:
```yaml
app:
analytics:
ga:
trackingId: UA-0000000-0
identity: optional
```
Set `identity` to `optional` if you need accurate session counts, including
cases where users do not sign in at all. Use `required` if you need all hits
to be associated with a user ID without exception (and don't mind if some
sessions are not captured, such as those where no sign in occur).
Note that, to comply with GA policies, the value of the User ID is
pseudonymized before being sent to GA. By default, it is a `sha256` hash of the
current user's `userEntityRef` as returned by the `identityApi`. To set a
different value, provide a `userIdTransform` function alongside `identityApi`
when you instantiate `GoogleAnalytics`. This function will be passed the
`userEntityRef` as an argument and should resolve to the value you wish to set
as the user ID. For example:
```typescript
import {
analyticsApiRef,
configApiRef,
identityApiRef,
} from '@backstage/core-plugin-api';
import { GoogleAnalytics } from '@backstage/plugin-analytics-module-ga';
export const apis: AnyApiFactory[] = [
createApiFactory({
api: analyticsApiRef,
deps: { configApi: configApiRef, identityApi: identityApiRef },
factory: ({ configApi, identityApi }) =>
GoogleAnalytics.fromConfig(configApi, {
identityApi,
userIdTransform: async (userEntityRef: string): Promise<string> => {
return customHashingFunction(userEntityRef);
},
}),
}),
];
```
### Debugging and Testing
In pre-production environments, you may wish to set additional configurations
@@ -147,3 +211,4 @@ app:
[what-is-a-custom-dimension]: https://support.google.com/analytics/answer/2709828
[configure-custom-dimension]: https://support.google.com/analytics/answer/2709828#configuration
[ga-user-id-view]: https://support.google.com/analytics/answer/3123669
+11 -6
View File
@@ -7,18 +7,23 @@ import { AnalyticsApi } from '@backstage/core-plugin-api';
import { AnalyticsEvent } from '@backstage/core-plugin-api';
import { BackstagePlugin } from '@backstage/core-plugin-api';
import { Config } from '@backstage/config';
import { IdentityApi } from '@backstage/core-plugin-api';
// Warning: (ae-missing-release-tag) "analyticsModuleGA" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
// @public @deprecated (undocumented)
export const analyticsModuleGA: BackstagePlugin<{}, {}>;
// Warning: (ae-missing-release-tag) "GoogleAnalytics" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public
export class GoogleAnalytics implements AnalyticsApi {
captureEvent(event: AnalyticsEvent): void;
static fromConfig(config: Config): GoogleAnalytics;
static fromConfig(
config: Config,
options?: {
identityApi?: IdentityApi;
userIdTransform?:
| 'sha-256'
| ((userEntityRef: string) => Promise<string>);
},
): GoogleAnalytics;
}
// (No @packageDocumentation comment for this package)
+19
View File
@@ -34,6 +34,25 @@ export interface Config {
*/
scriptSrc?: string;
/**
* Controls how the identityApi is used when sending data to GA:
*
* - `disabled`: (Default) Explicitly prevents a user's identity from
* being used when capturing events in GA.
* - `optional`: Pageviews and hits are forwarded to GA as they happen
* and only include user identity metadata once known. Guarantees
* that hits are captured for all sessions, even if no sign in
* occurs, but may result in dropped hits in User ID views.
* - `required`: All pageviews and hits are deferred until an identity
* is known. Guarantees that all data sent to GA correlates to a user
* identity, but prevents GA from receiving events for sessions in
* which a user does not sign in. An `identityApi` instance must be
* passed during instantiation when set to this value.
*
* @visibility frontend
*/
identity?: 'disabled' | 'optional' | 'required';
/**
* Whether or not to log analytics debug statements to the console.
* Defaults to false.
@@ -15,10 +15,17 @@
*/
import { ConfigReader } from '@backstage/config';
import { IdentityApi } from '@backstage/core-plugin-api';
import ReactGA from 'react-ga';
import { GoogleAnalytics } from './GoogleAnalytics';
describe('GoogleAnalytics', () => {
const context = {
extension: 'App',
pluginId: 'some-plugin',
routeRef: 'unknown',
releaseNum: 1337,
};
const trackingId = 'UA-000000-0';
const basicValidConfig = new ConfigReader({
app: { analytics: { ga: { trackingId, testMode: true } } },
@@ -50,12 +57,6 @@ describe('GoogleAnalytics', () => {
});
describe('integration', () => {
const context = {
extension: 'App',
pluginId: 'some-plugin',
routeRef: 'unknown',
releaseNum: 1337,
};
const advancedConfig = new ConfigReader({
app: {
analytics: {
@@ -141,20 +142,13 @@ describe('GoogleAnalytics', () => {
context,
});
// Expect a set command first.
const [setCommand, setData] = ReactGA.testModeAPI.calls[1];
expect(setCommand).toBe('set');
expect(setData).toMatchObject({
dimension1: context.pluginId,
metric1: context.releaseNum,
});
// Followed by a send command.
const [sendCommand, sendData] = ReactGA.testModeAPI.calls[2];
expect(sendCommand).toBe('send');
expect(sendData).toMatchObject({
const [command, data] = ReactGA.testModeAPI.calls[1];
expect(command).toBe('send');
expect(data).toMatchObject({
hitType: 'pageview',
page: '/a-page',
dimension1: context.pluginId,
metric1: context.releaseNum,
});
});
@@ -208,4 +202,214 @@ describe('GoogleAnalytics', () => {
});
});
});
describe('identityApi', () => {
const identityApi = {
getBackstageIdentity: jest.fn().mockResolvedValue({
userEntityRef: 'User:default/someone',
}),
} as unknown as IdentityApi;
it('does not set userId unless explicitly configured', async () => {
// Instantiate with identityApi and default configs.
const api = GoogleAnalytics.fromConfig(basicValidConfig, { identityApi });
api.captureEvent({
action: 'navigate',
subject: '/',
context,
});
// Wait for any/all promises involved to settle.
await new Promise(resolve => setImmediate(resolve));
// A pageview should have been fired immediately.
const [command, data] = ReactGA.testModeAPI.calls[1];
expect(command).toBe('send');
expect(data).toMatchObject({
hitType: 'pageview',
page: '/',
});
// There should not have been a UserID set.
expect(ReactGA.testModeAPI.calls).toHaveLength(2);
});
it('sets hashed userId when identityApi is provided', async () => {
// Instantiate with identityApi and identity set to optional
const optionalConfig = new ConfigReader({
app: {
analytics: {
ga: { trackingId, testMode: true, identity: 'optional' },
},
},
});
const api = GoogleAnalytics.fromConfig(optionalConfig, { identityApi });
api.captureEvent({
action: 'navigate',
subject: '/',
context,
});
// Wait for any/all promises involved to settle.
await new Promise(resolve => setImmediate(resolve));
// A pageview should have been fired immediately.
const [command, data] = ReactGA.testModeAPI.calls[1];
expect(command).toBe('send');
expect(data).toMatchObject({
hitType: 'pageview',
page: '/',
});
// User ID should have been set after the pageview.
const [setCommand, setData] = ReactGA.testModeAPI.calls[2];
expect(setCommand).toBe('set');
expect(setData).toMatchObject({
// String indicating userEntityRef went through expected hashing.
userId: '557365723a64656661756c742f736f6d656f6e65',
});
});
it('set custom-hashed userId when userIdTransform is provided', async () => {
const userIdTransform = jest.fn().mockResolvedValue('s0m3hash3dvalu3');
const optionalConfig = new ConfigReader({
app: {
analytics: {
ga: { trackingId, testMode: true, identity: 'optional' },
},
},
});
const api = GoogleAnalytics.fromConfig(optionalConfig, {
identityApi,
userIdTransform,
});
api.captureEvent({
action: 'navigate',
subject: '/',
context,
});
// Wait for any/all promises involved to settle.
await new Promise(resolve => setImmediate(resolve));
// User ID should have been set after the pageview.
const [setCommand, setData] = ReactGA.testModeAPI.calls[2];
expect(setCommand).toBe('set');
expect(setData).toMatchObject({
userId: 's0m3hash3dvalu3',
});
expect(userIdTransform).toHaveBeenCalledWith('User:default/someone');
});
it('does not set userId when identityApi is provided and ga.identity is explicitly disabled', async () => {
// Instantiate with identityApi and identity explicitly disabled.
const disabledConfig = new ConfigReader({
app: {
analytics: {
ga: { trackingId, testMode: true, identity: 'disabled' },
},
},
});
const api = GoogleAnalytics.fromConfig(disabledConfig, { identityApi });
api.captureEvent({
action: 'navigate',
subject: '/',
context,
});
// Wait for any/all promises involved to settle.
await new Promise(resolve => setImmediate(resolve));
// A pageview should have been fired immediately.
const [command, data] = ReactGA.testModeAPI.calls[1];
expect(command).toBe('send');
expect(data).toMatchObject({
hitType: 'pageview',
page: '/',
});
// There should not have been a UserID set.
expect(ReactGA.testModeAPI.calls).toHaveLength(2);
});
it('throws error when ga.identity is required but no identityApi is provided', async () => {
// Instantiate without identityApi and identity explicitly disabled.
const requiredConfig = new ConfigReader({
app: {
analytics: {
ga: { trackingId, testMode: true, identity: 'required' },
},
},
});
expect(() => GoogleAnalytics.fromConfig(requiredConfig)).toThrow();
});
it('defers event capture when ga.identity is required', async () => {
// Instantiate with identityApi and identity explicitly required.
const requiredConfig = new ConfigReader({
app: {
analytics: {
ga: { trackingId, testMode: true, identity: 'required' },
},
},
});
const api = GoogleAnalytics.fromConfig(requiredConfig, { identityApi });
// Fire a pageview and an event.
api.captureEvent({
action: 'navigate',
subject: '/',
context,
});
api.captureEvent({
action: 'test',
subject: 'some label',
context,
});
// Wait for any/all promises involved to settle.
await new Promise(resolve => setImmediate(resolve));
// User ID should have been set first.
const [setCommand, setData] = ReactGA.testModeAPI.calls[1];
expect(setCommand).toBe('set');
expect(setData).toMatchObject({
// String indicating userEntityRef went through expected hashing.
userId: '557365723a64656661756c742f736f6d656f6e65',
});
// Then a pageview should have been fired with a queue time.
const [pageCommand, pageData] = ReactGA.testModeAPI.calls[2];
expect(pageCommand).toBe('send');
expect(pageData).toMatchObject({
hitType: 'pageview',
page: '/',
queueTime: expect.any(Number),
});
// Then an event should have been fired with a queue time.
const [eventCommand, eventData] = ReactGA.testModeAPI.calls[3];
expect(eventCommand).toBe('send');
expect(eventData).toMatchObject({
hitType: 'event',
queueTime: expect.any(Number),
});
// And subsequent hits should not have a queue time.
api.captureEvent({
action: 'navigate',
subject: '/page-2',
context,
});
const [lastCommand, lastData] = ReactGA.testModeAPI.calls[4];
expect(lastCommand).toBe('send');
expect(lastData).toMatchObject({
hitType: 'pageview',
page: '/page-2',
});
expect(lastData.queueTime).toBeUndefined();
});
});
});
@@ -20,8 +20,10 @@ import {
AnalyticsContextValue,
AnalyticsEventAttributes,
AnalyticsEvent,
IdentityApi,
} from '@backstage/core-plugin-api';
import { Config } from '@backstage/config';
import { DeferredCapture } from '../../../util';
type CustomDimensionOrMetricConfig = {
type: 'dimension' | 'metric';
@@ -32,21 +34,36 @@ type CustomDimensionOrMetricConfig = {
/**
* Google Analytics API provider for the Backstage Analytics API.
* @public
*/
export class GoogleAnalytics implements AnalyticsApi {
private readonly cdmConfig: CustomDimensionOrMetricConfig[];
private customUserIdTransform?: (userEntityRef: string) => Promise<string>;
private readonly capture: DeferredCapture;
/**
* Instantiate the implementation and initialize ReactGA.
*/
private constructor(options: {
identityApi?: IdentityApi;
userIdTransform?: 'sha-256' | ((userEntityRef: string) => Promise<string>);
cdmConfig: CustomDimensionOrMetricConfig[];
identity: string;
trackingId: string;
scriptSrc?: string;
testMode: boolean;
debug: boolean;
}) {
const { cdmConfig, trackingId, scriptSrc, testMode, debug } = options;
const {
cdmConfig,
identity,
trackingId,
identityApi,
userIdTransform = 'sha-256',
scriptSrc,
testMode,
debug,
} = options;
this.cdmConfig = cdmConfig;
@@ -57,15 +74,37 @@ export class GoogleAnalytics implements AnalyticsApi {
gaAddress: scriptSrc,
titleCase: false,
});
// If identity is required, defer event capture until identity is known.
this.capture = new DeferredCapture({ defer: identity === 'required' });
// Allow custom userId transformation.
this.customUserIdTransform =
typeof userIdTransform === 'function' ? userIdTransform : undefined;
// Capture user only when explicitly enabled and provided.
if (identity !== 'disabled' && identityApi) {
this.setUserFrom(identityApi);
}
}
/**
* Instantiate a fully configured GA Analytics API implementation.
*/
static fromConfig(config: Config) {
static fromConfig(
config: Config,
options: {
identityApi?: IdentityApi;
userIdTransform?:
| 'sha-256'
| ((userEntityRef: string) => Promise<string>);
} = {},
) {
// Get all necessary configuration.
const trackingId = config.getString('app.analytics.ga.trackingId');
const scriptSrc = config.getOptionalString('app.analytics.ga.scriptSrc');
const identity =
config.getOptionalString('app.analytics.ga.identity') || 'disabled';
const debug = config.getOptionalBoolean('app.analytics.ga.debug') ?? false;
const testMode =
config.getOptionalBoolean('app.analytics.ga.testMode') ?? false;
@@ -83,8 +122,16 @@ export class GoogleAnalytics implements AnalyticsApi {
};
}) ?? [];
if (identity === 'required' && !options.identityApi) {
throw new Error(
'Invalid config: identity API must be provided to deps when ga.identity is required',
);
}
// Return an implementation instance.
return new GoogleAnalytics({
...options,
identity,
trackingId,
scriptSrc,
cdmConfig,
@@ -103,16 +150,11 @@ export class GoogleAnalytics implements AnalyticsApi {
const customMetadata = this.getCustomDimensionMetrics(context, attributes);
if (action === 'navigate' && context.extension === 'App') {
// Set any/all custom dimensions.
if (Object.keys(customMetadata).length) {
ReactGA.set(customMetadata);
}
ReactGA.pageview(subject);
this.capture.pageview(subject, customMetadata);
return;
}
ReactGA.event({
this.capture.event({
category: context.extension || 'App',
action,
label: subject,
@@ -151,4 +193,59 @@ export class GoogleAnalytics implements AnalyticsApi {
return customDimensionsMetrics;
}
/**
* Sets the GA userId, based on the `userEntityRef` set on the backstage
* identity loaded from a given Backstage Identity API instance. Because
* Google forbids sending any PII (including on the userId field), we hash
* the entire `userEntityRef` on behalf of integrators:
*
* - With value `User:default/name`, userId becomes `sha256(User:default/name)`
*
* If an integrator wishes to use an alternative hashing mechanism or an
* entirely different value, they may do so by passing a `userIdTransform`
* function alongside the `identityApi` to `GoogleAnalytics.fromConfig()`.
* This function receives the `userEntityRef` as an argument and should
* resolve to a hashed version of whatever identifier they choose.
*
* Note: this feature requires that an integrator has set up a Google
* Analytics User ID view in the property used to track Backstage.
*/
private async setUserFrom(identityApi: IdentityApi) {
const { userEntityRef } = await identityApi.getBackstageIdentity();
// Prevent PII from being passed to Google Analytics.
const userId = await this.getPrivateUserId(userEntityRef);
// Set the user ID.
ReactGA.set({ userId });
// Notify the deferred capture mechanism that it may proceed.
this.capture.setReady();
}
/**
* Returns a PII-free (according to Google's terms of service) user ID for
* use in Google Analytics.
*/
private getPrivateUserId(userEntityRef: string): Promise<string> {
// Allow integrators to provide their own hashing transformer.
if (this.customUserIdTransform) {
return this.customUserIdTransform(userEntityRef);
}
return this.hash(userEntityRef);
}
/**
* Simple hash function; relies on web cryptography + the sha-256 algorithm.
*/
private async hash(value: string): Promise<string> {
const digest = await crypto.subtle.digest(
'sha-256',
new TextEncoder().encode(value),
);
const hashArray = Array.from(new Uint8Array(digest));
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
}
}
+1 -1
View File
@@ -15,4 +15,4 @@
*/
export { analyticsModuleGA } from './plugin';
export { GoogleAnalytics } from './apis/implementations/AnalyticsApi';
export * from './apis/implementations/AnalyticsApi';
@@ -15,6 +15,12 @@
*/
import { createPlugin } from '@backstage/core-plugin-api';
/**
* @deprecated Importing and including this plugin in an app has no effect.
* This will be removed in a future release.
*
* @public
*/
export const analyticsModuleGA = createPlugin({
id: 'analytics-provider-ga',
});
@@ -15,3 +15,20 @@
*/
import '@testing-library/jest-dom';
import 'cross-fetch/polyfill';
// eslint-disable-next-line no-restricted-imports
import { TextEncoder } from 'util';
// Mock browser crypto.subtle.digest method for sha-256 hashing.
Object.defineProperty(global.self, 'crypto', {
value: {
subtle: {
digest: (_algo: string, data: Uint8Array): ArrayBuffer => data.buffer,
},
},
});
// Also used in browser-based APIs for hashing.
Object.defineProperty(global.self, 'TextEncoder', {
value: TextEncoder,
});
@@ -0,0 +1,110 @@
/*
* Copyright 2022 The Backstage 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
*
* http://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.
*/
import ReactGA from 'react-ga';
type Hit = {
timestamp: number;
data: {
hitType: 'pageview' | 'event';
[x: string]: any;
};
};
/**
* A wrapper around ReactGA that can optionally handle latent capture logic.
*
* - When defer is `false`, event data is sent directly to GA.
* - When defer is `true`, event data is queued (with a timestamp), so that it
* can be sent to GA once externally indicated to be ready. This relies on
* the `qt` or `queueTime` parameter of the Measurement Protocol.
*
* @see https://developers.google.com/analytics/devguides/collection/protocol/v1/parameters#qt
*/
export class DeferredCapture {
/**
* Queue of deferred hits to be processed when ready. When undefined, hits
* can safely be sent without delay.
*/
private queue: Hit[] | undefined;
constructor({ defer = false }: { defer: boolean }) {
this.queue = defer ? [] : undefined;
}
/**
* Indicates that deferred capture may now proceed.
*/
setReady() {
if (this.queue) {
this.queue.forEach(this.sendDeferred);
this.queue = undefined;
}
}
/**
* Either forwards the pageview directly to GA, or (if configured) enqueues
* the pageview hit to be captured when ready.
*/
pageview(path: string, metadata: ReactGA.FieldsObject = {}) {
if (this.queue) {
this.queue.push({
timestamp: Date.now(),
data: {
hitType: 'pageview',
page: path,
...metadata,
},
});
return;
}
ReactGA.send({
hitType: 'pageview',
page: path,
...metadata,
});
}
/**
* Either forwards the event directly to GA, or (if configured) enqueues the
* event hit to be captured when ready.
*/
event(eventDetails: ReactGA.EventArgs) {
if (this.queue) {
this.queue.push({
timestamp: Date.now(),
data: {
...eventDetails,
hitType: 'event',
},
});
return;
}
ReactGA.event(eventDetails);
}
/**
* Sends a given hit to GA, decorated with the correct queue time.
*/
private sendDeferred(hit: Hit) {
// Send the hit with the appropriate queue time (`qt`).
ReactGA.send({
...hit.data,
queueTime: Date.now() - hit.timestamp,
});
}
}
@@ -0,0 +1,17 @@
/*
* Copyright 2022 The Backstage 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
*
* http://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.
*/
export { DeferredCapture } from './DeferredCapture';