task(graphql): Send notifications when users opt in/out of metrics collection

Because:
- We want to let RPs know when users opt out of metrics collection
- We want to let RPs know when users opt in to metrics collection

This Commit:
- Ports the notifier code over to nx libs from auth server
- Ports other supporting libraries from fxa-shared to nx libs
  - MozLoggerService
  - Sentry
  - Metrics (ie statsd)
- Updates graphql to emit a 'metricsChange' event when users toggle their 'Help improve Mozilla accounts' option in settings.
- Adds support for the metricsChanged event to the fxa-event-broker
This commit is contained in:
dschom 2024-04-23 14:39:29 -07:00
Родитель ca6c2b3f07
Коммит 8b9051f881
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: F26AEE99174EE68B
87 изменённых файлов: 3881 добавлений и 82 удалений

Просмотреть файл

@ -2,5 +2,5 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
export { StatsD } from 'hot-shots';
export { localStatsD } from './lib/statsd';
export * from './lib/statsd';
export * from './lib/statsd.provider';

Просмотреть файл

@ -0,0 +1,53 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigService } from '@nestjs/config';
import { StatsDFactory, StatsDService } from './statsd.provider';
import { StatsD } from 'hot-shots';
const mockStatsd = jest.fn();
jest.mock('hot-shots', () => {
return {
StatsD: function (...args: any) {
return mockStatsd(...args);
},
};
});
describe('StatsDFactory', () => {
let statsd: StatsD;
const mockConfig = {
host: 'test',
};
const mockConfigService = {
get: jest.fn().mockImplementation((key: string) => {
if (key === 'metrics') {
return mockConfig;
}
return null;
}),
};
beforeEach(async () => {
jest.clearAllMocks();
const module: TestingModule = await Test.createTestingModule({
providers: [
StatsDFactory,
{
provide: ConfigService,
useValue: mockConfigService,
},
],
}).compile();
statsd = await module.resolve<StatsD>(StatsDService);
});
it('should provide statsd', async () => {
expect(statsd).toBeDefined();
expect(mockStatsd).toBeCalledWith(mockConfig);
});
});

Просмотреть файл

@ -0,0 +1,20 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { Provider } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { StatsD } from 'hot-shots';
export const StatsDService = Symbol('STATSD');
export const StatsDFactory: Provider<StatsD> = {
provide: StatsDService,
useFactory: (configService: ConfigService) => {
const config = configService.get('metrics');
if (config.host === '') {
return new StatsD({ mock: true });
}
return new StatsD(config);
},
inject: [ConfigService],
};

Просмотреть файл

@ -0,0 +1,18 @@
{
"extends": ["../../../.eslintrc.json"],
"ignorePatterns": ["!**/*"],
"overrides": [
{
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
"rules": {}
},
{
"files": ["*.ts", "*.tsx"],
"rules": {}
},
{
"files": ["*.js", "*.jsx"],
"rules": {}
}
]
}

Просмотреть файл

@ -0,0 +1,9 @@
# shared-mozlog
This library was generated with [Nx](https://nx.dev).
This library is considered deprecated, and should only be used in scenarios where legacy code is in play. Using the logger implementation provided by libs/shared/log is now preferred.
## Running unit tests
Run `nx test-unit shared-mozlog` to execute the unit tests via [Jest](https://jestjs.io).

Просмотреть файл

@ -0,0 +1,11 @@
/* eslint-disable */
export default {
displayName: 'shared-mozlog',
preset: '../../../jest.preset.js',
testEnvironment: 'node',
transform: {
'^.+\\.[tj]s$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.spec.json' }],
},
moduleFileExtensions: ['ts', 'js', 'html'],
coverageDirectory: '../../../coverage/libs/shared/mozlog',
};

Просмотреть файл

@ -0,0 +1,4 @@
{
"name": "@fxa/shared/mozlog",
"version": "0.0.0"
}

Просмотреть файл

@ -0,0 +1,36 @@
{
"name": "shared-mozlog",
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/shared/mozlog/src",
"projectType": "library",
"tags": [],
"targets": {
"build": {
"executor": "@nx/js:tsc",
"outputs": ["{options.outputPath}"],
"options": {
"outputPath": "dist/libs/shared/mozlog",
"main": "libs/shared/mozlog/src/index.ts",
"tsConfig": "libs/shared/mozlog/tsconfig.lib.json",
"assets": ["libs/shared/mozlog/*.md"]
}
},
"lint": {
"executor": "@nx/linter:eslint",
"outputs": ["{options.outputFile}"],
"options": {
"lintFilePatterns": [
"libs/shared/mozlog/**/*.ts",
"libs/shared/mozlog/package.json"
]
}
},
"test-unit": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "libs/shared/mozlog/jest.config.ts"
}
}
}
}

Просмотреть файл

@ -0,0 +1,5 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
export { MozLoggerService } from './lib/mozlog.service';

Просмотреть файл

@ -0,0 +1,104 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigService } from '@nestjs/config';
import { MozLoggerService } from './mozlog.service';
const mockMozLog = {
info: jest.fn(),
error: jest.fn(),
warn: jest.fn(),
debug: jest.fn(),
verbose: jest.fn(),
trace: jest.fn(),
warning: jest.fn(),
};
const mockMozLogLoggerFactory = jest.fn().mockReturnValue(mockMozLog);
const mockMozLogDefault = jest.fn().mockReturnValue(mockMozLogLoggerFactory);
jest.mock('mozlog', () => {
return (...args: any) => {
return mockMozLogDefault(...args);
};
});
describe('MozLoggerService', () => {
let service: MozLoggerService;
const mockConfig = {
app: 'fxa-test',
level: 'info',
fmt: 'heka',
};
const mockConfigService = {
get: jest.fn().mockImplementation((key: string) => {
if (key === 'log') {
return mockConfig;
}
return null;
}),
};
beforeEach(async () => {
jest.clearAllMocks();
const module: TestingModule = await Test.createTestingModule({
providers: [
MozLoggerService,
{
provide: ConfigService,
useValue: mockConfigService,
},
],
}).compile();
service = await module.resolve<MozLoggerService>(MozLoggerService);
});
it('should be defined', async () => {
expect(service).toBeDefined();
expect(service).toBeInstanceOf(MozLoggerService);
expect(mockMozLogDefault).toHaveBeenCalledWith(mockConfig);
expect(mockMozLogLoggerFactory).toHaveBeenCalledWith('default');
});
it('sets context', () => {
service.setContext('test');
expect(mockMozLogLoggerFactory).toHaveBeenCalledWith('test');
});
it('logs info', () => {
service.info('info', {});
expect(mockMozLog.info).toBeCalledWith('info', {});
});
it('logs debug', () => {
service.debug('debug', {});
expect(mockMozLog.debug).toBeCalledWith('debug', {});
});
it('logs error', () => {
service.error('error', {});
expect(mockMozLog.error).toBeCalledWith('error', {});
});
it('logs warn', () => {
service.warn('warn', {});
expect(mockMozLog.warn).toBeCalledWith('warn', {});
});
it('logs verbose', () => {
service.verbose('verbose', {});
expect(mockMozLog.verbose).toBeCalledWith('verbose', {});
});
it('logs trace', () => {
service.trace('trace', {});
expect(mockMozLog.trace).toBeCalledWith('trace', {});
});
it('logs warning', () => {
service.warn('warning', {});
expect(mockMozLog.warn).toBeCalledWith('warning', {});
});
});

Просмотреть файл

@ -0,0 +1,53 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { Injectable, Scope } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import mozlog, { Logger as MozLogger, LoggerFactory } from 'mozlog';
let logFactory: LoggerFactory;
@Injectable({ scope: Scope.TRANSIENT })
export class MozLoggerService {
private mozlog: MozLogger;
constructor(configService: ConfigService) {
if (!logFactory) {
logFactory = mozlog(configService.get('log'));
}
this.mozlog = logFactory('default');
}
public setContext(name: string): void {
this.mozlog = logFactory(name);
}
info(type: string, fields: Record<string, any>): void {
this.mozlog.info(type, fields);
}
error(type: string, fields: Record<string, any>): void {
this.mozlog.error(type, fields);
}
warn(type: string, fields: Record<string, any>): void {
this.mozlog.warn(type, fields);
}
debug(type: string, fields: Record<string, any>): void {
this.mozlog.debug(type, fields);
}
verbose(type: string, fields: Record<string, any>): void {
this.mozlog.verbose(type, fields);
}
trace(type: string, fields: Record<string, any>): void {
this.mozlog.trace(type, fields);
}
warning(type: string, fields: Record<string, any>): void {
this.mozlog.warn(type, fields);
}
}

Просмотреть файл

@ -0,0 +1,16 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"module": "commonjs"
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}

Просмотреть файл

@ -0,0 +1,11 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"module": "commonjs",
"outDir": "../../../dist/out-tsc",
"declaration": true,
"types": ["node"]
},
"exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"],
"include": ["src/**/*.ts"]
}

Просмотреть файл

@ -0,0 +1,14 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../dist/out-tsc",
"module": "commonjs",
"types": ["jest", "node"]
},
"include": [
"jest.config.ts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.d.ts"
]
}

Просмотреть файл

@ -0,0 +1,18 @@
{
"extends": ["../../../.eslintrc.json"],
"ignorePatterns": ["!**/*"],
"overrides": [
{
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
"rules": {}
},
{
"files": ["*.ts", "*.tsx"],
"rules": {}
},
{
"files": ["*.js", "*.jsx"],
"rules": {}
}
]
}

Просмотреть файл

@ -0,0 +1,7 @@
# notifier
This library was generated with [Nx](https://nx.dev).
## Running unit tests
Run `nx test notifier` to execute the unit tests via [Jest](https://jestjs.io).

Просмотреть файл

@ -0,0 +1,11 @@
/* eslint-disable */
export default {
displayName: 'notifier',
preset: '../../../jest.preset.js',
testEnvironment: 'node',
transform: {
'^.+\\.[tj]s$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.spec.json' }],
},
moduleFileExtensions: ['ts', 'js', 'html'],
coverageDirectory: '../../../coverage/libs/shared/notifier',
};

Просмотреть файл

@ -0,0 +1,4 @@
{
"name": "@fxa/shared/notifier",
"version": "0.0.1"
}

Просмотреть файл

@ -0,0 +1,36 @@
{
"name": "shared-notifier",
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/shared/notifier/src",
"projectType": "library",
"targets": {
"build": {
"executor": "@nx/js:tsc",
"outputs": ["{options.outputPath}"],
"options": {
"outputPath": "dist/libs/shared/notifier",
"main": "libs/shared/notifier/src/index.ts",
"tsConfig": "libs/shared/notifier/tsconfig.lib.json",
"assets": ["libs/shared/notifier/*.md"]
}
},
"lint": {
"executor": "@nx/linter:eslint",
"outputs": ["{options.outputFile}"],
"options": {
"lintFilePatterns": [
"libs/shared/notifier/**/*.ts",
"libs/shared/notifier/package.json"
]
}
},
"test-unit": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "libs/shared/notifier/jest.config.ts"
}
}
},
"tags": []
}

Просмотреть файл

@ -0,0 +1,8 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
// export * from './lib/notifier.module';
export * from './lib/notifier.service';
export * from './lib/notifier.sns.config';
export * from './lib/notifier.sns.provider';

Просмотреть файл

@ -0,0 +1,168 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { Test, TestingModule } from '@nestjs/testing';
import { NotifierService } from './notifier.service';
import { ConfigService } from '@nestjs/config';
import { NotifierSnsService } from './notifier.sns.provider';
import { MozLoggerService } from '@fxa/shared/mozlog';
import { StatsDService } from '@fxa/shared/metrics/statsd';
describe('NotifierService', () => {
let service: NotifierService;
const mockStatsD = {
timing: jest.fn(),
error: jest.fn(),
trace: jest.fn(),
};
const mockLogger = {
error: jest.fn(),
debug: jest.fn(),
};
const mockConfig = {
snsTopicArn: 'arn:aws:sns:us-east-1:100010001000:fxa-account-change-dev',
snsTopicEndpoint: 'http://localhost:4100/',
};
const mockConfigService = {
get: jest.fn().mockImplementation((key: string) => {
if (key === 'notifier.sns') {
return mockConfig;
}
return null;
}),
};
const mockSnsService = {
publish: jest.fn(),
};
beforeEach(async () => {
jest.clearAllMocks();
const module: TestingModule = await Test.createTestingModule({
providers: [
NotifierService,
{
provide: ConfigService,
useValue: mockConfigService,
},
{
provide: StatsDService,
useValue: mockStatsD,
},
{
provide: MozLoggerService,
useValue: mockLogger,
},
{
provide: NotifierSnsService,
useValue: mockSnsService,
},
],
}).compile();
service = module.get<NotifierService>(NotifierService);
});
it('should be defined', async () => {
expect(service).toBeDefined();
expect(service).toBeInstanceOf(NotifierService);
});
it('sends without error', () => {
const callback = jest.fn();
const responseData = {
SequenceNumber: 'foo',
};
const event = {
data: {
email: 'foo@mozilla.com',
bar: true,
},
event: 'foo',
};
mockSnsService.publish.mockImplementation((params, callback) => {
service.onPublish(undefined, responseData, Date.now() - 100, callback);
});
service.send(event, callback);
expect(mockSnsService.publish).toBeCalledWith(
{
TopicArn: mockConfig.snsTopicArn,
Message: JSON.stringify({
email: event.data.email,
bar: event.data.bar,
event: event.event,
}),
MessageAttributes: {
event_type: {
DataType: 'String',
StringValue: event.event,
},
email_domain: {
DataType: 'String',
StringValue: event.data.email.split('@')[1],
},
},
},
expect.anything()
);
expect(mockStatsD.timing).toBeCalledWith(
'notifier.publish',
expect.any(Number)
);
expect(mockLogger.debug).toBeCalledWith('Notifier.publish', {
success: true,
data: responseData,
});
expect(callback).toBeCalledWith(undefined, responseData);
});
it('sends and encounters error', () => {
const err = {
code: '500',
name: 'foo',
message: 'bar',
time: new Date(),
};
const callback = jest.fn();
const responseData = {
SequenceNumber: 'foo',
};
const event = {
event: 'foo',
data: {},
};
mockSnsService.publish.mockImplementation((params, callback) => {
service.onPublish(err, responseData, Date.now() - 100, callback);
});
service.send(event, callback);
expect(mockSnsService.publish).toBeCalledWith(
{
TopicArn: mockConfig.snsTopicArn,
Message: JSON.stringify({
event: event.event,
}),
MessageAttributes: {
event_type: {
DataType: 'String',
StringValue: event.event,
},
},
},
expect.anything()
);
expect(mockStatsD.timing).toBeCalledWith(
'notifier.publish',
expect.any(Number)
);
expect(mockLogger.error).toBeCalledWith('Notifier.publish', {
err,
});
expect(callback).toBeCalledWith(err, responseData);
});
});

Просмотреть файл

@ -0,0 +1,105 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { Inject, Injectable } from '@nestjs/common';
import { StatsD } from 'hot-shots';
import { SNS, AWSError } from 'aws-sdk';
import { NotifierSnsService } from './notifier.sns.provider';
import { ConfigService } from '@nestjs/config';
import { NotifierSnsConfig } from './notifier.sns.config';
import { MozLoggerService } from '@fxa/shared/mozlog';
import { StatsDService } from '@fxa/shared/metrics/statsd';
@Injectable()
export class NotifierService {
private readonly config: NotifierSnsConfig;
constructor(
configService: ConfigService,
private readonly log: MozLoggerService,
@Inject(NotifierSnsService) private readonly sns: SNS,
@Inject(StatsDService) private readonly statsd: StatsD
) {
const config = configService.get<NotifierSnsConfig>('notifier.sns');
if (config == null) {
throw new Error('Could not locate sns.notifier config');
}
this.log.error('Creating notifier service', {
config: JSON.stringify(config),
});
if (!config.snsTopicArn) {
throw new Error('Config error snsTopicArnMissing');
}
if (!config.snsTopicEndpoint) {
throw new Error('Config error notifierSnsTopicEndpoint missing');
}
this.config = config;
}
send(
event: any,
callback?: (err: AWSError | undefined, data: SNS.PublishResponse) => void
) {
const msg = event.data || {};
msg.event = event.event;
const startTime = Date.now();
this.sns.publish(
{
TopicArn: this.config.snsTopicArn,
Message: JSON.stringify(msg),
MessageAttributes: this.formatMessageAttributes(msg),
},
(err: AWSError, data: SNS.PublishResponse) => {
this.onPublish(err, data, startTime, callback);
}
);
}
public onPublish(
err: AWSError | undefined,
data: SNS.PublishResponse,
startTime: number,
callback?: (err: AWSError | undefined, data: SNS.PublishResponse) => void
) {
if (this.statsd) {
this.statsd.timing('notifier.publish', Date.now() - startTime);
}
if (err) {
this.log.error('Notifier.publish', { err });
} else {
this.log.debug('Notifier.publish', { success: true, data });
}
if (callback) {
callback(err, data);
}
}
private formatMessageAttributes(msg: {
event: string;
email: string;
}): SNS.MessageAttributeMap {
const map: SNS.MessageAttributeMap = {};
map['event_type'] = {
DataType: 'String',
StringValue: msg.event,
};
if (msg.email) {
map['email_domain'] = {
DataType: 'String',
StringValue: msg.email.split('@')[1],
};
}
return map;
}
}

Просмотреть файл

@ -0,0 +1,13 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { IsString, IsUrl } from 'class-validator';
export class NotifierSnsConfig {
@IsString()
public readonly snsTopicArn!: string;
@IsUrl()
public readonly snsTopicEndpoint!: string;
}

Просмотреть файл

@ -0,0 +1,60 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { Test, TestingModule } from '@nestjs/testing';
import { ConfigService } from '@nestjs/config';
import { SNS } from 'aws-sdk';
import {
NotifierSnsFactory,
NotifierSnsService,
} from './notifier.sns.provider';
const mockSNS = jest.fn();
jest.mock('aws-sdk', () => {
return {
SNS: function (...args: any) {
return mockSNS(...args);
},
};
});
describe('NotifierSnsFactory', () => {
let sns: SNS;
const mockConfig = {
snsTopicArn: 'arn:aws:sns:us-east-1:100010001000:fxa-account-change-dev',
snsTopicEndpoint: 'http://localhost:4100/',
};
const mockConfigService = {
get: jest.fn().mockImplementation((key: string) => {
if (key === 'notifier.sns') {
return mockConfig;
}
return null;
}),
};
beforeEach(async () => {
jest.clearAllMocks();
const module: TestingModule = await Test.createTestingModule({
providers: [
NotifierSnsFactory,
{
provide: ConfigService,
useValue: mockConfigService,
},
],
}).compile();
sns = await module.resolve<SNS>(NotifierSnsService);
});
it('should provide statsd', async () => {
expect(sns).toBeDefined();
expect(mockSNS).toBeCalledWith({
endpoint: mockConfig.snsTopicEndpoint,
region: 'us-east-1',
});
});
});

Просмотреть файл

@ -0,0 +1,39 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { SNS } from 'aws-sdk';
import { Provider } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { NotifierSnsConfig } from './notifier.sns.config';
/**
* Creates a SNS instance from a config object.
* @param config
* @returns A SNS instance
*/
export function setupSns(config: NotifierSnsConfig) {
const endpoint = config.snsTopicEndpoint;
const region = config.snsTopicArn.split(':')[3];
return new SNS({
region,
endpoint,
});
}
/**
* Factory for providing access to SNS
*/
export const NotifierSnsService = Symbol('NOTIFIER_SNS');
export const NotifierSnsFactory: Provider<SNS> = {
provide: NotifierSnsService,
useFactory: (configService: ConfigService) => {
const config = configService.get<NotifierSnsConfig>('notifier.sns');
if (config == null) {
throw new Error('Could not locate notifier.sns config');
}
const sns = setupSns(config);
return sns;
},
inject: [ConfigService],
};

Просмотреть файл

@ -0,0 +1,16 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"module": "commonjs"
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}

Просмотреть файл

@ -0,0 +1,11 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"module": "commonjs",
"outDir": "../../../dist/out-tsc",
"declaration": true,
"types": ["node"]
},
"exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"],
"include": ["src/**/*.ts"]
}

Просмотреть файл

@ -0,0 +1,14 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../dist/out-tsc",
"module": "commonjs",
"types": ["jest", "node"]
},
"include": [
"jest.config.ts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.d.ts"
]
}

Просмотреть файл

@ -0,0 +1,18 @@
{
"extends": ["../../../.eslintrc.json"],
"ignorePatterns": ["!**/*"],
"overrides": [
{
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
"rules": {}
},
{
"files": ["*.ts", "*.tsx"],
"rules": {}
},
{
"files": ["*.js", "*.jsx"],
"rules": {}
}
]
}

Просмотреть файл

@ -0,0 +1,7 @@
# sentry
This library was generated with [Nx](https://nx.dev).
## Running unit tests
Run `nx test sentry` to execute the unit tests via [Jest](https://jestjs.io).

Просмотреть файл

@ -0,0 +1,11 @@
/* eslint-disable */
export default {
displayName: 'sentry',
preset: '../../../jest.preset.js',
testEnvironment: 'node',
transform: {
'^.+\\.[tj]s$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.spec.json' }],
},
moduleFileExtensions: ['ts', 'js', 'html'],
coverageDirectory: '../../../coverage/libs/shared/sentry',
};

Просмотреть файл

@ -0,0 +1,4 @@
{
"name": "@fxa/shared/sentry",
"version": "0.0.1"
}

Просмотреть файл

@ -0,0 +1,36 @@
{
"name": "shared-sentry",
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/shared/sentry/src",
"projectType": "library",
"tags": [],
"targets": {
"build": {
"executor": "@nx/js:tsc",
"outputs": ["{options.outputPath}"],
"options": {
"outputPath": "dist/libs/shared/sentry",
"main": "libs/shared/sentry/src/index.ts",
"tsConfig": "libs/shared/sentry/tsconfig.lib.json",
"assets": ["libs/shared/sentry/*.md"]
}
},
"lint": {
"executor": "@nx/linter:eslint",
"outputs": ["{options.outputFile}"],
"options": {
"lintFilePatterns": [
"libs/shared/sentry/**/*.ts",
"libs/shared/sentry/package.json"
]
}
},
"test-unit": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "libs/shared/sentry/jest.config.ts"
}
}
}
}

Просмотреть файл

@ -0,0 +1,10 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
export * from './lib/nest/sentry.plugin';
export * from './lib/nest/sentry.interceptor';
export * from './lib/nest/sentry.constants';
export * from './lib/reporting';
export * from './lib/node';
export * from './lib/browser';

Просмотреть файл

@ -0,0 +1,224 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import 'jsdom-global/register';
import * as Sentry from '@sentry/browser';
import sentryMetrics, { _Sentry } from './browser';
import { SentryConfigOpts } from './models/SentryConfigOpts';
import { Logger } from './sentry.types';
const sinon = require('sinon');
const sandbox = sinon.createSandbox();
const config: SentryConfigOpts = {
release: 'v0.0.0',
sentry: {
dsn: 'https://public:private@host:8080/1',
env: 'test',
clientName: 'fxa-shared-testing',
sampleRate: 0,
},
};
const logger: Logger = {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
};
describe('sentry/browser', () => {
beforeAll(() => {
// Reduce console log noise in test output
sandbox.spy(console, 'error');
});
beforeEach(() => {
// Make sure it's enabled by default
sentryMetrics.enable();
});
afterAll(() => {
sandbox.restore();
});
describe('init', () => {
it('properly configures with dsn', () => {
sentryMetrics.configure(config, logger);
});
});
describe('beforeSend', () => {
beforeAll(() => {
sentryMetrics.configure(config, logger);
});
it('works without request url', () => {
const data = {
key: 'value',
} as Sentry.Event;
const resultData = sentryMetrics.__beforeSend(config, data, {});
expect(data).toEqual(resultData);
});
it('fingerprints errno', () => {
const data = {
request: {
url: 'https://example.com',
},
tags: {
errno: '100',
},
} as Sentry.Event;
const resultData = sentryMetrics.__beforeSend(config, data, {});
expect(resultData?.fingerprint?.[0]).toEqual('errno100');
expect(resultData?.level).toEqual('info');
});
it('properly erases sensitive information from url', () => {
const url = 'https://accounts.firefox.com/complete_reset_password';
const badQuery =
'?token=foo&code=bar&email=some%40restmail.net&service=sync';
const goodQuery = '?token=VALUE&code=VALUE&email=VALUE&service=sync';
const badData = {
request: {
url: url + badQuery,
},
};
const goodData = {
request: {
url: url + goodQuery,
},
};
const resultData = sentryMetrics.__beforeSend(config, badData);
expect(resultData?.request?.url).toEqual(goodData.request.url);
});
it('properly erases sensitive information from referrer', () => {
const url = 'https://accounts.firefox.com/complete_reset_password';
const badQuery =
'?token=foo&code=bar&email=some%40restmail.net&service=sync';
const goodQuery = '?token=VALUE&code=VALUE&email=VALUE&service=sync';
const badData = {
request: {
headers: {
Referer: url + badQuery,
},
},
};
const goodData = {
request: {
headers: {
Referer: url + goodQuery,
},
},
};
const resultData = sentryMetrics.__beforeSend(config, badData);
expect(resultData?.request?.headers?.Referer).toEqual(
goodData.request.headers.Referer
);
});
it('properly erases sensitive information from abs_path', () => {
const url = 'https://accounts.firefox.com/complete_reset_password';
const badCulprit =
'https://accounts.firefox.com/scripts/57f6d4e4.main.js';
const badAbsPath =
'https://accounts.firefox.com/complete_reset_password?token=foo&code=bar&email=a@a.com&service=sync&resume=barbar';
const goodAbsPath =
'https://accounts.firefox.com/complete_reset_password?token=VALUE&code=VALUE&email=VALUE&service=sync&resume=VALUE';
const data = {
culprit: badCulprit,
exception: {
values: [
{
stacktrace: {
frames: [
{
abs_path: badAbsPath, // eslint-disable-line camelcase
},
{
abs_path: badAbsPath, // eslint-disable-line camelcase
},
],
},
},
],
},
request: {
url,
},
};
const resultData = sentryMetrics.__beforeSend(config, data);
expect(
resultData?.exception?.values?.[0].stacktrace?.frames?.[0].abs_path
).toEqual(goodAbsPath);
expect(
resultData?.exception?.values?.[0].stacktrace?.frames?.[1].abs_path
).toEqual(goodAbsPath);
});
});
describe('cleanUpQueryParam', () => {
it('properly erases sensitive information', () => {
const fixtureUrl1 =
'https://accounts.firefox.com/complete_reset_password?token=foo&code=bar&email=some%40restmail.net';
const expectedUrl1 =
'https://accounts.firefox.com/complete_reset_password?token=VALUE&code=VALUE&email=VALUE';
const resultUrl1 = sentryMetrics.__cleanUpQueryParam(fixtureUrl1);
expect(resultUrl1).toEqual(expectedUrl1);
});
it('properly erases sensitive information, keeps allowed fields', () => {
const fixtureUrl2 =
'https://accounts.firefox.com/signup?client_id=foo&service=sync';
const expectedUrl2 =
'https://accounts.firefox.com/signup?client_id=foo&service=sync';
const resultUrl2 = sentryMetrics.__cleanUpQueryParam(fixtureUrl2);
expect(resultUrl2).toEqual(expectedUrl2);
});
it('properly returns the url when there is no query', () => {
const expectedUrl = 'https://accounts.firefox.com/signup';
const resultUrl = sentryMetrics.__cleanUpQueryParam(expectedUrl);
expect(resultUrl).toEqual(expectedUrl);
});
});
describe('captureException', () => {
it('calls Sentry.captureException', () => {
const sentryCaptureException = sinon.stub(_Sentry, 'captureException');
sentryMetrics.captureException(new Error('testo'));
sinon.assert.calledOnce(sentryCaptureException);
sentryCaptureException.restore();
});
});
describe('disable / enables', () => {
it('enables', () => {
sentryMetrics.enable();
expect(sentryMetrics.__sentryEnabled()).toBeTruthy();
});
it('disables', () => {
sentryMetrics.disable();
expect(sentryMetrics.__sentryEnabled()).toBeFalsy();
});
it('will return null from before send when disabled', () => {
sentryMetrics.disable();
expect(sentryMetrics.__beforeSend({}, {}, {})).toBeNull();
});
});
});

Просмотреть файл

@ -0,0 +1,193 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import * as Sentry from '@sentry/browser';
import { SentryConfigOpts } from './models/SentryConfigOpts';
import { tagFxaName } from './reporting';
import { buildSentryConfig } from './config-builder';
import { Logger } from './sentry.types';
/**
* Query parameters we allow to propagate to sentry
*/
const ALLOWED_QUERY_PARAMETERS = [
'automatedBrowser',
'client_id',
'context',
'entrypoint',
'keys',
'migration',
'redirect_uri',
'scope',
'service',
'setting',
'style',
];
/**
* Exception fields that are imported as tags
*/
const EXCEPTION_TAGS = ['code', 'context', 'errno', 'namespace', 'status'];
// Internal flag to keep track of whether or not sentry is initialized
let sentryEnabled = false;
// HACK: allow tests to stub this function from Sentry
// https://stackoverflow.com/questions/35240469/how-to-mock-the-imports-of-an-es6-module
export const _Sentry = {
captureException: Sentry.captureException,
close: Sentry.close,
};
/**
* function that gets called before data gets sent to error metrics
*
* @param {Object} event
* Error object data
* @returns {Object} data
* Modified error object data
* @private
*/
function beforeSend(opts: SentryConfigOpts, event: Sentry.Event, hint?: any) {
if (sentryEnabled === false) {
return null;
}
if (event.request) {
if (event.request.url) {
event.request.url = cleanUpQueryParam(event.request.url);
}
if (event.tags) {
// if this is a known errno, then use grouping with fingerprints
// Docs: https://docs.sentry.io/hosted/learn/rollups/#fallback-grouping
if (event.tags.errno) {
event.fingerprint = ['errno' + (event.tags.errno as number)];
// if it is a known error change the error level to info.
event.level = 'info';
}
}
if (event.exception?.values) {
event.exception.values.forEach((value: Sentry.Exception) => {
if (value.stacktrace && value.stacktrace.frames) {
value.stacktrace.frames.forEach((frame: { abs_path?: string }) => {
if (frame.abs_path) {
frame.abs_path = cleanUpQueryParam(frame.abs_path); // eslint-disable-line camelcase
}
});
}
});
}
if (event.request.headers?.Referer) {
event.request.headers.Referer = cleanUpQueryParam(
event.request.headers.Referer
);
}
}
event = tagFxaName(event, opts.sentry?.clientName || opts.sentry?.serverName);
return event;
}
/**
* Overwrites sensitive query parameters with a dummy value.
*
* @param {String} url
* @returns {String} url
* @private
*/
function cleanUpQueryParam(url = '') {
const urlObj = new URL(url);
if (!urlObj.search.length) {
return url;
}
// Iterate the search parameters.
urlObj.searchParams.forEach((_, key) => {
if (!ALLOWED_QUERY_PARAMETERS.includes(key)) {
// if the param is a PII (not allowed) then reset the value.
urlObj.searchParams.set(key, 'VALUE');
}
});
return urlObj.href;
}
function captureException(err: Error) {
if (!sentryEnabled) {
return;
}
Sentry.withScope((scope: Sentry.Scope) => {
EXCEPTION_TAGS.forEach((tagName) => {
if (tagName in err) {
scope.setTag(
tagName,
(
err as {
[key: string]: any;
}
)[tagName]
);
}
});
_Sentry.captureException(err);
});
}
function disable() {
sentryEnabled = false;
}
function enable() {
sentryEnabled = true;
}
function configure(config: SentryConfigOpts, log?: Logger) {
if (!log) {
log = console;
}
if (!config?.sentry?.dsn) {
log.error('No Sentry dsn provided');
return;
}
// We want sentry to be disabled by default... This is because we only emit data
// for users that 'have opted in'. A subsequent call to 'enable' is needed to ensure
// that sentry events only flow under the proper circumstances.
disable();
const opts = buildSentryConfig(config, log);
try {
Sentry.init({
...opts,
integrations: [
Sentry.browserTracingIntegration({
enableInp: true,
}),
],
beforeSend: function (event: Sentry.Event, hint?: any) {
return beforeSend(opts, event, hint);
},
});
} catch (e) {
log.error(e);
}
}
export default {
configure,
captureException,
disable,
enable,
__sentryEnabled: function () {
return sentryEnabled;
},
__beforeSend: beforeSend,
__cleanUpQueryParam: cleanUpQueryParam,
};

Просмотреть файл

@ -0,0 +1,133 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { buildSentryConfig } from './config-builder';
import { SentryConfigOpts } from './models/SentryConfigOpts';
import { Logger } from './sentry.types';
describe('config-builder', () => {
function cloneConfig(val: any) {
return structuredClone(val);
}
const mockLogger: Logger = {
info: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
debug: jest.fn(),
};
afterEach(() => {
jest.resetAllMocks();
});
const testConfig: SentryConfigOpts = {
release: '1.0.1',
version: '1.0.2',
sentry: {
dsn: 'https://foo.sentry.io',
env: 'test',
sampleRate: 1,
serverName: 'fxa-shared-test',
clientName: 'fxa-shared-client-test',
},
};
it('builds', () => {
const config = buildSentryConfig(testConfig, mockLogger);
expect(config).toBeDefined();
expect(mockLogger.info).toBeCalledWith('sentry-config-builder', {
msg: `Config setting for sentry.dsn specified, enabling sentry for env ${testConfig.sentry?.env}!`,
});
});
it('picks correct defaults', () => {
const config = buildSentryConfig(testConfig, mockLogger);
expect(config.environment).toEqual(testConfig.sentry?.env);
expect(config.release).toEqual(testConfig.release);
expect(config.fxaName).toEqual(testConfig.sentry?.clientName);
});
it('falls back', () => {
const clone = cloneConfig(testConfig);
delete clone.sentry.clientName;
delete clone.release;
const config = buildSentryConfig(clone, mockLogger);
expect(config.release).toEqual(testConfig.version);
expect(config.fxaName).toEqual(testConfig.sentry?.serverName);
});
it('warns about missing config', () => {
const clone = cloneConfig(testConfig);
clone.sentry.dsn = '';
buildSentryConfig(clone, mockLogger);
expect(mockLogger.warn).toBeCalledTimes(1);
});
it('errors on missing dsn', () => {
const clone = cloneConfig(testConfig);
clone.sentry.strict = true;
clone.sentry.dsn = '';
expect(() => {
buildSentryConfig(clone, mockLogger);
}).toThrow('sentry.dsn not specified. sentry disabled.');
expect(mockLogger.warn).toBeCalledTimes(1);
});
it('errors on unknown environment', () => {
const clone = cloneConfig(testConfig);
clone.sentry.strict = true;
clone.sentry.env = 'xyz';
expect(() => {
buildSentryConfig(clone, mockLogger);
}).toThrow(
'invalid config.env. xyz options are: test,local,dev,ci,stage,prod,production,development'
);
expect(mockLogger.warn).toBeCalledTimes(1);
});
it('errors on missing release', () => {
const clone = cloneConfig(testConfig);
clone.sentry.strict = true;
delete clone.release;
delete clone.version;
expect(() => {
buildSentryConfig(clone, mockLogger);
}).toThrow('config missing either release or version.');
expect(mockLogger.warn).toBeCalledTimes(1);
});
it('errors on missing sampleRate', () => {
const clone = cloneConfig(testConfig);
clone.sentry.strict = true;
delete clone.sentry.sampleRate;
expect(() => {
buildSentryConfig(clone, mockLogger);
}).toThrow('sentry.sampleRate');
expect(mockLogger.warn).toBeCalledTimes(1);
});
it('can use moz logger', () => {
const mozlog = require('mozlog')({
app: 'fxa-shared-test',
level: 'trace',
});
const logger = mozlog('fxa-shared-testing');
const config = buildSentryConfig(testConfig, logger);
expect(config).toBeDefined();
});
it('can use console logger', () => {
const config = buildSentryConfig(testConfig, console);
expect(config).toBeDefined();
});
});

Просмотреть файл

@ -0,0 +1,87 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { Logger } from './sentry.types';
import { SentryConfigOpts } from './models/SentryConfigOpts';
const sentryEnvMap: Record<string, string> = {
test: 'test',
local: 'local',
dev: 'dev',
ci: 'ci',
stage: 'stage',
prod: 'prod',
production: 'prod',
development: 'dev',
};
function toEnv(val: any) {
if (typeof val === 'string') {
return sentryEnvMap[val] || '';
}
return '';
}
export function buildSentryConfig(config: SentryConfigOpts, log: Logger) {
if (log) {
checkSentryConfig(config, log);
}
const opts = {
dsn: config.sentry?.dsn || '',
release: config.release || config.version,
environment: toEnv(config.sentry?.env),
sampleRate: config.sentry?.sampleRate,
clientName: config.sentry?.clientName,
serverName: config.sentry?.serverName,
fxaName: config.sentry?.clientName || config.sentry?.serverName,
tracesSampleRate: config.sentry?.tracesSampleRate,
};
return opts;
}
function checkSentryConfig(config: SentryConfigOpts, log: Logger) {
if (!config || !config.sentry || !config.sentry?.dsn) {
raiseError('sentry.dsn not specified. sentry disabled.');
return;
} else {
log?.info('sentry-config-builder', {
msg: `Config setting for sentry.dsn specified, enabling sentry for env ${config.sentry.env}!`,
});
}
if (!config.sentry.env) {
raiseError('config missing either environment or env.');
} else if (!toEnv(config.sentry.env)) {
raiseError(
`invalid config.env. ${config.sentry.env} options are: ${Object.keys(
sentryEnvMap
).join(',')}`
);
} else {
log?.info('sentry-config-builder', {
msg: 'sentry targeting: ' + sentryEnvMap[config.sentry.env],
});
}
if (!config.release && !config.version) {
raiseError('config missing either release or version.');
}
if (config.sentry?.sampleRate == null) {
raiseError('config missing sentry.sampleRate');
}
if (!config.sentry.clientName && !config.sentry.serverName) {
raiseError('config missing either sentry.clientName or sentry.serverName');
}
function raiseError(msg: string) {
log?.warn('sentry-config-builder', { msg });
if (config.sentry?.strict) {
throw new SentryConfigurationBuildError(msg);
}
}
}
class SentryConfigurationBuildError extends Error {}

Просмотреть файл

@ -0,0 +1,25 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import joi from 'joi';
import { overrideJoiMessages } from './joi-message-overrides';
describe('joi-message-overrides', () => {
it('overrides default message for regex', () => {
const validators = {
test: joi.string().regex(/test/),
};
const result1 = validators.test.validate('foobar').error?.message;
const validators2 = overrideJoiMessages(validators);
const result2 = validators2.test.validate('foobar').error?.message;
expect(validators2).toBeDefined();
expect(result1).toBeDefined();
expect(result2).toBeDefined();
expect(result1).not.toEqual(result2);
expect(result1).toContain('with value');
expect(result2).not.toContain('with value');
});
});

Просмотреть файл

@ -0,0 +1,40 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { AnySchema } from 'joi';
/**
* A set of default message overrides. These result in better error resolution in sentry.
*/
export const defaultMessageOverrides = {
// Override some of the message defaults. Here we remove the 'with value {:[.]}'
// portion of the message, because it causes too much fragmentation in our sentry
// errors. These should be applied to any .regex or .pattern joi validator.
// Form more context concerning overriding messages see:
// - https://joi.dev/api/?v=17.6.0#anymessagesmessages
// - https://github.com/hapijs/joi/blob/7aa36666863c1dde7e4eb02a8058e00555a99d54/lib/types/string.js#L718
'string.pattern.base':
'{{#label}} fails to match the required pattern: {{#regex}}',
'string.pattern.name': '{{#label}} fails to match the {{#name}} pattern',
'string.pattern.invert.base':
'{{#label}} matches the inverted pattern: {{#regex}}',
'string.pattern.invert.name':
'{{#label}} matches the inverted {{#name}} pattern',
};
/**
* Applies a set of message overrides to the default joi message formats.
* @param data - Set of joi validators to apply message overrides to to. Note, data is mutated.
* @param overrides - Set of optional overrides, if none are provide the defaultMessageOverrides are used.
* @returns data
*/
export function overrideJoiMessages(
data: Record<string, AnySchema>,
overrides?: Record<string, string>
) {
Object.keys(data).forEach(
(x) => (data[x] = data[x].messages(overrides || defaultMessageOverrides))
);
return data;
}

Просмотреть файл

@ -0,0 +1,31 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
export type SentryConfigOpts = {
/** Name of release */
release?: string;
/** Fall back for name of release */
version?: string;
/** Sentry specific settings */
sentry?: {
/** The datasource name. This value can be obtained from the sentry portal. */
dsn?: string;
/** The environment name. */
env?: string;
/** The rate (as percent between 0 and 1) at which errors are sampled. Can be reduced to decrease data volume. */
sampleRate?: number;
/** The name of the active client. */
clientName?: string;
/** The name of the active server. */
serverName?: string;
/** When set to true, building a configuration will throw an error critical fields are missing. */
strict?: boolean;
/** The tracing sample rate. Setting this above 0 will aso result in performance metrics being captured. */
tracesSampleRate?: number;
};
};

Просмотреть файл

@ -0,0 +1,41 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
/** A general type that holds PII data. */
export type PiiData = Record<string, any> | string | undefined | null;
/**
* Result of a filter action.
*/
export interface FilterActionResult<T> {
/**
* The modified value
*/
val: T;
/**
* Whether or not the pipeline can be exited. In the event the filter removes enough data, it might
* make sense to exit the pipeline of filter actions early.
*/
exitPipeline: boolean;
}
/** A general interface for running a filter action on PII Data */
export interface IFilterAction {
/**
* Filters a value for PII
* @param val - the value to filter
* @param depth - if filtering an object, the depth of the current traversal
* @returns the provided value with modifications, and flag if the action pipeline can be exited.
*/
execute<T extends PiiData>(val: T, depth?: number): FilterActionResult<T>;
}
/** A general interface for top level classes that filter PII data */
export interface IFilter {
filter(event: PiiData): PiiData;
}
/** Things to check for when scrubbing for PII. */
export type CheckOnly = 'keys' | 'values' | 'both';

Просмотреть файл

@ -0,0 +1,4 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
export const SENTRY_CONFIG = Symbol('SENTRY_CONFIG');

Просмотреть файл

@ -0,0 +1,57 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { Observable } from 'rxjs';
import { finalize, tap } from 'rxjs/operators';
import {
CallHandler,
ExecutionContext,
HttpException,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import * as Sentry from '@sentry/node';
import { Transaction } from '@sentry/types';
import { isApolloError, processException } from '../reporting';
@Injectable()
export class SentryInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
// If there is http context request start a transaction for it. Note that this will not
// pick up graphql queries
const req = context.switchToHttp().getRequest();
let transaction: Transaction;
if (req) {
transaction = Sentry.startTransaction({
op: 'nestjs.http',
name: `${req.method} ${req.path}`,
});
}
return next.handle().pipe(
tap({
error: (exception) => {
// Skip HttpExceptions with status code < 500.
if (
exception instanceof HttpException ||
exception.constructor.name === 'HttpException'
) {
if ((exception as HttpException).getStatus() < 500) {
return;
}
}
// Skip ApolloErrors
if (isApolloError(exception)) return;
processException(context, exception);
},
}),
finalize(() => {
transaction?.finish();
})
);
}
}

Просмотреть файл

@ -0,0 +1,137 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
/**
* Apollo-Server plugin for Sentry
*
* Modeled after:
* https://blog.sentry.io/2020/07/22/handling-graphql-errors-using-sentry
*
* This makes the following assumptions about the Apollo Server setup:
* 1. The request object to Apollo's context as `.req`.
* 2. `SentryPlugin` is passed in the `plugins` option.
*/
import { Request } from 'express';
import {
ApolloServerPlugin,
BaseContext,
GraphQLRequestListener,
GraphQLRequestContext,
} from '@apollo/server';
import { Plugin } from '@nestjs/apollo';
import * as Sentry from '@sentry/node';
import { Transaction } from '@sentry/types';
import {
ExtraContext,
isApolloError,
isOriginallyHttpError,
reportRequestException,
} from '../reporting';
import { Inject } from '@nestjs/common';
import { MozLoggerService } from '@fxa/shared/mozlog';
interface Context extends BaseContext {
transaction: Transaction;
request: Request;
}
export async function createContext(ctx: any): Promise<Context> {
const transaction = Sentry.startTransaction({
op: 'gql',
name: 'GraphQLTransaction',
});
return { request: ctx.req, transaction };
}
@Plugin()
export class SentryPlugin implements ApolloServerPlugin<Context> {
constructor(@Inject(MozLoggerService) private log: MozLoggerService) {}
async requestDidStart({
request,
contextValue,
}: GraphQLRequestContext<Context>): Promise<GraphQLRequestListener<any>> {
const log = this.log;
if (request.operationName != null) {
try {
contextValue.transaction.setName(request.operationName);
} catch (err) {
log.error('sentry-plugin', err);
}
}
return {
async willSendResponse({ contextValue }) {
try {
contextValue.transaction.finish();
} catch (err) {
log.error('sentry-plugin', err);
}
},
async executionDidStart() {
return {
willResolveField({ contextValue, info }) {
let span: any;
try {
span = contextValue.transaction.startChild({
op: 'resolver',
description: `${info.parentType.name}.${info.fieldName}`,
});
} catch (err) {
log.error('sentry-plugin', err);
}
return () => {
span?.finish();
};
},
};
},
async didEncounterErrors({ contextValue, errors, operation }) {
// If we couldn't parse the operation, don't
// do anything here
if (!operation) {
return;
}
for (const err of errors) {
// Only report internal server errors,
// all errors extending ApolloError should be user-facing
if (isApolloError(err)) {
continue;
}
// Skip errors that are originally http errors. There are two expected scenarios where this happens:
// 1. When we hit a case where auth server responds with an http error
// 2. When we hit an unauthorized state due to an invalid session token
//
// In either case, the error is considered expected, and should not be reported to sentry.
//
if (isOriginallyHttpError(err as any)) {
continue;
}
const excContexts: ExtraContext[] = [];
if ((err as any).path?.join) {
excContexts.push({
name: 'graphql',
fieldData: {
path: err.path?.join(' > ') ?? '',
},
});
}
reportRequestException(
err.originalError ?? err,
excContexts,
contextValue.request
);
}
},
};
}
}

Просмотреть файл

@ -0,0 +1,62 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import * as Sentry from '@sentry/node';
import { ErrorEvent } from '@sentry/types';
import { ExtraErrorData } from '@sentry/integrations';
import { SentryConfigOpts } from './models/SentryConfigOpts';
import { buildSentryConfig } from './config-builder';
import { tagFxaName } from './reporting';
import { Logger } from './sentry.types';
export type ExtraOpts = {
integrations?: any[];
eventFilters?: Array<(event: ErrorEvent, hint: any) => ErrorEvent>;
};
export type InitSentryOpts = SentryConfigOpts & ExtraOpts;
export function initSentry(config: InitSentryOpts, log: Logger) {
if (!config?.sentry?.dsn) {
log.error('No Sentry dsn provided. Cannot start sentry');
return;
}
const opts = buildSentryConfig(config, log);
const beforeSend = function (event: ErrorEvent, hint: any) {
// Default
event = tagFxaName(event, config.sentry?.serverName || 'unknown');
// Custom filters
config.eventFilters?.forEach((filter) => {
event = filter(event, hint);
});
return event;
};
const integrations = [
// Default
new ExtraErrorData({ depth: 5 }),
// Custom Integrations
...(config.integrations || []),
];
try {
Sentry.init({
// Defaults Options
instrumenter: 'otel',
normalizeDepth: 6,
maxValueLength: 500,
// Custom Options
integrations,
beforeSend,
...opts,
});
} catch (e) {
log.debug('init-sentry', { msg: 'Issue initializing sentry!' });
log.error('init-sentry', e);
}
}

Просмотреть файл

@ -0,0 +1,380 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import * as uuid from 'uuid';
import {
CommonPiiActions,
DepthFilter,
TRUNCATED,
FILTERED,
PiiRegexFilter,
BreadthFilter,
} from './filter-actions';
describe('pii-filter-actions', () => {
describe('DepthFilter', () => {
it('truncates objects', () => {
const filter = new DepthFilter(1);
expect(filter.execute('foo', 1)).toEqual({
val: 'foo',
exitPipeline: false,
});
expect(filter.execute(null, 1)).toEqual({
val: null,
exitPipeline: true,
});
});
it('truncates objects when depth is greater than max depth', () => {
const filter = new DepthFilter(1);
expect(filter.execute({ foo: 'bar' }, 2)).toEqual({
val: {
foo: TRUNCATED,
},
exitPipeline: true,
});
});
it('does not truncate if depth is less than max depth ', () => {
const filter = new DepthFilter(1);
expect(filter.execute({ foo: 'bar' }, 0)).toEqual({
val: { foo: 'bar' },
exitPipeline: false,
});
});
it('handles null', () => {
const filter = new DepthFilter(1);
expect(filter.execute(null, 1)).toEqual({
val: null,
exitPipeline: true,
});
});
});
describe('BreadthFilter', () => {
it('truncates objects', () => {
const filter = new BreadthFilter(1);
expect(filter.execute('foo')).toEqual({
val: 'foo',
exitPipeline: false,
});
expect(filter.execute(null)).toEqual({
val: null,
exitPipeline: true,
});
});
it('truncates object of size greater than max breadth', () => {
const filter = new BreadthFilter(1);
expect(filter.execute({ foo: '1', bar: '2', baz: '3' })).toEqual({
val: {
foo: '1',
[TRUNCATED]: 2,
},
exitPipeline: false,
});
});
it('does not truncate object of size equal to max breadth', () => {
const filter = new BreadthFilter(3);
expect(filter.execute(['foo', 'bar', 'baz'])).toEqual({
val: ['foo', 'bar', 'baz'],
exitPipeline: false,
});
});
it('does not truncate object of size less than max breadth', () => {
const filter = new BreadthFilter(5);
expect(filter.execute(['foo', 'bar', 'baz'])).toEqual({
val: ['foo', 'bar', 'baz'],
exitPipeline: false,
});
});
it('truncates array of size greater than max breadth', () => {
const filter = new BreadthFilter(1);
expect(filter.execute(['foo', 'bar', 'baz'])).toEqual({
val: ['foo', `${TRUNCATED}:2`],
exitPipeline: false,
});
});
it('does not truncate array of size less than max breadth', () => {
const filter = new BreadthFilter(5);
expect(filter.execute(['foo', 'bar', 'baz'])).toEqual({
val: ['foo', 'bar', 'baz'],
exitPipeline: false,
});
});
it('does not truncate array of size equal to max breadth', () => {
const filter = new BreadthFilter(3);
expect(filter.execute(['foo', 'bar', 'baz'])).toEqual({
val: ['foo', 'bar', 'baz'],
exitPipeline: false,
});
});
it('handles empty array', () => {
const filter = new BreadthFilter(1);
expect(filter.execute([])).toEqual({
val: [],
exitPipeline: true,
});
});
it('handles empty object', () => {
const filter = new BreadthFilter(1);
expect(filter.execute({})).toEqual({
val: {},
exitPipeline: true,
});
});
});
describe('PiiRegexFilter', () => {
it('filters string', () => {
const filter = new PiiRegexFilter(/foo/gi, 'values', '[BAR]');
const value = filter.execute('test foo regex filter');
expect(value).toEqual({
val: 'test [BAR] regex filter',
exitPipeline: false,
});
});
it('filters string and determines exitPipeline', () => {
const filter = new PiiRegexFilter(/foo/gi, 'values', '[BAR]');
const value = filter.execute('foo');
expect(value).toEqual({ val: '[BAR]', exitPipeline: true });
});
it('filters object value', () => {
const filter1 = new PiiRegexFilter(/foo/gi, 'both', '[BAR]');
const filter2 = new PiiRegexFilter(/foo/gi, 'values', '[BAR]');
const { val: value1 } = filter1.execute({
item: 'test foo regex filter',
});
const { val: value2 } = filter2.execute({
item: 'test foo regex filter',
});
expect(value1.item).toEqual('test [BAR] regex filter');
expect(value2.item).toEqual('test [BAR] regex filter');
});
it('filters object key', () => {
const filter = new PiiRegexFilter(/foo/gi, 'keys', '[BAR]');
const { val: value } = filter.execute({
foo: 'test foo regex filter',
});
expect(value.foo).toEqual('[BAR]');
});
describe('checksOn', () => {
it('checks on values', () => {
const filter = new PiiRegexFilter(/foo/gi, 'values', '[BAR]');
const { val: value } = filter.execute({
foo: 'test foo regex filter',
bar: 'test foo regex filter',
});
expect(value.foo).toEqual('test [BAR] regex filter');
expect(value.bar).toEqual('test [BAR] regex filter');
});
it('checks on keys', () => {
const filter = new PiiRegexFilter(/foo/gi, 'keys', '[BAR]');
const { val: value } = filter.execute({
foo: 'test foo regex filter',
bar: 'test foo regex filter',
});
expect(value.foo).toEqual('[BAR]');
expect(value.bar).toEqual('test foo regex filter');
});
it('checks on keys and values', () => {
const filter = new PiiRegexFilter(/foo/gi, 'both', '[BAR]');
const { val: value } = filter.execute({
foo: 'test foo regex filter',
bar: 'test foo regex filter',
});
expect(value.foo).toEqual('[BAR]');
expect(value.bar).toEqual('test [BAR] regex filter');
});
});
});
describe('CommonPiiActions', () => {
it('filters emails', () => {
const { val: result } = CommonPiiActions.emailValues.execute({
foo: 'email: test@123.com -- 123@test.com --',
bar: '123',
});
expect(result).toEqual({
foo: `email: ${FILTERED} -- ${FILTERED} --`,
bar: '123',
});
});
it('filters email in url', () => {
const { val: result } = CommonPiiActions.emailValues.execute(
'http://foo.bar/?email=foxkey@mozilla.com&key=1'
);
expect(result).toEqual(`http://foo.bar/?email=${FILTERED}&key=1`);
});
it('filters email in route', () => {
const { val: result } = CommonPiiActions.emailValues.execute(
'/account?email=foxkey@mozilla.com&key=1'
);
expect(result).toEqual(`/account?email=${FILTERED}&key=1`);
});
it('filters email in query', () => {
const { val: result } = CommonPiiActions.emailValues.execute(
`where email='test@mozilla.com'`
);
expect(result).toEqual(`where email='${FILTERED}'`);
});
it('filters username / password from url', () => {
const { val: result } = CommonPiiActions.urlUsernamePassword.execute(
'http://me:wut@foo.bar/'
);
expect(result).toEqual(`http://${FILTERED}:${FILTERED}@foo.bar/`);
});
it('ipv6 values', () => {
const { val: result } = CommonPiiActions.ipV6Values.execute({
foo: 'ipv6: 2001:0db8:85a3:0000:0000:8a2e:0370:7334 -- FE80:0000:0000:0000:0202:B3FF:FE1E:8329 --',
bar: '123',
});
expect(result).toEqual({
foo: `ipv6: ${FILTERED} -- ${FILTERED} --`,
bar: '123',
});
});
it('ipv4 values', () => {
const { val: result } = CommonPiiActions.ipV4Values.execute({
foo: '-- 127.0.0.1 -- 10.0.0.1 -- ',
bar: '1.2.3',
});
expect(result).toEqual({
foo: `-- ${FILTERED} -- ${FILTERED} -- `,
bar: '1.2.3',
});
});
it('filters pii keys', () => {
const { val: result } = CommonPiiActions.piiKeys.execute({
'oidc-test': 'foo',
'OIDC-TEST': 'foo',
'remote-groups': 'foo',
'REMOTE-GROUPS': 'foo',
email_address: 'foo',
email: 'foo',
EmailAddress: 'foo',
ip: 'foo',
ip_addr: 'foo',
ip_address: 'foo',
IpAddress: 'foo',
uid: 'foo',
user: 'foo',
username: 'foo',
user_name: 'foo',
UserName: 'foo',
userid: 'foo',
UserId: 'foo',
user_id: 'foo',
bar: '123',
});
expect(result).toEqual({
'oidc-test': FILTERED,
'OIDC-TEST': FILTERED,
'remote-groups': FILTERED,
'REMOTE-GROUPS': FILTERED,
email: FILTERED,
email_address: FILTERED,
EmailAddress: FILTERED,
ip: FILTERED,
ip_addr: FILTERED,
ip_address: FILTERED,
IpAddress: FILTERED,
uid: FILTERED,
user: FILTERED,
username: FILTERED,
user_name: FILTERED,
UserName: FILTERED,
userid: FILTERED,
user_id: FILTERED,
UserId: FILTERED,
bar: '123',
});
});
it('filters token values', () => {
const token1 = uuid.v4().replace(/-/g, '');
const token2 = uuid.v4().replace(/-/g, '');
const token3 = uuid.v4().toString();
const { val: result } = CommonPiiActions.tokenValues.execute({
foo: `-- ${token1}\n${token2}--`,
bar: token3,
});
expect(result).toEqual({
foo: `-- ${FILTERED}\n${FILTERED}--`,
bar: token3,
});
});
it('filters 64 byte token values', () => {
const token1 = uuid.v4().replace(/-/g, '');
const { val: result } = CommonPiiActions.tokenValues.execute({
foo: `X'${token1}${token1}'`,
});
expect(result).toEqual({
foo: `X'${FILTERED}'`,
});
});
it('filters token value in url', () => {
const result = CommonPiiActions.tokenValues.execute(
'https://foo.bar/?uid=12345678123456781234567812345678'
);
expect(result.val).toEqual(`https://foo.bar/?uid=${FILTERED}`);
});
it('filters token value in db statement', () => {
const result = CommonPiiActions.tokenValues.execute(
`Call accountDevices_17(X'cce22e4006d243c895c7596e2cad53d8',500)`
);
expect(result.val).toEqual(`Call accountDevices_17(X'${FILTERED}',500)`);
});
it('filters token value in db query', () => {
const result = CommonPiiActions.tokenValues.execute(
` where uid = X'cce22e4006d243c895c7596e2cad53d8' `
);
expect(result.val).toEqual(` where uid = X'${FILTERED}' `);
});
it('filters multiple multiline token values', () => {
const token = '12345678123456781234567812345678';
const { val: result } = CommonPiiActions.tokenValues.execute(
`${token}--${token}\n${token}`
);
expect(result).toEqual(`${FILTERED}--${FILTERED}\n${FILTERED}`);
});
});
});

Просмотреть файл

@ -0,0 +1,338 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { CheckOnly, IFilterAction, PiiData } from '../models/pii';
/** Default replacement values */
export const FILTERED = '[Filtered]';
export const TRUNCATED = '[Truncated]';
/**
* A filter that truncates anything over maxDepth. This is a good first action.
*/
export class DepthFilter implements IFilterAction {
/**
* Crete new depth filter.
* @param maxDepth - The max depth allowed in the tree. The first level is considered is index as 0.
*/
constructor(protected readonly maxDepth = 3) {}
execute<T extends PiiData>(val: T, depth = 0) {
let exitPipeline = false;
if (val == null) {
exitPipeline = true;
} else if (depth > this.maxDepth && typeof val === 'object') {
Object.keys(val)?.forEach((x) => {
val[x] = TRUNCATED;
});
exitPipeline = true;
}
return { val, exitPipeline };
}
}
/**
* A filter truncates any object or array containing too many entries.
*/
export class BreadthFilter implements IFilterAction {
/**
* Create new breadth filter
* @param maxBreadth max number of values in object / array
*/
constructor(protected readonly maxBreadth: number) {}
execute<T extends PiiData>(val: T) {
let exitPipeline = false;
if (val == null) {
exitPipeline = true;
} else if (typeof val === 'object') {
if (val instanceof Array) {
exitPipeline = this.maxBreadth == 0 || val.length === 0;
const deleted = val.splice(this.maxBreadth);
// Leave some indication of what was deleted
if (deleted?.length) {
val.push(`${TRUNCATED}:${deleted.length}`);
}
} else {
const keys = Object.keys(val);
let count = 0;
for (const x of keys) {
if (++count > this.maxBreadth) {
delete val[x];
}
}
// Leave some indication of what was deleted
if (count > this.maxBreadth) {
val[TRUNCATED] = count - this.maxBreadth;
}
exitPipeline = keys.length === 0 || this.maxBreadth === 0;
}
}
return { val, exitPipeline };
}
}
/**
* A base class for other PiiFilters. Supports checking keys and values
*/
export abstract class PiiFilter implements IFilterAction {
/** Flag determining if object values should be checked. */
protected get checkValues() {
return this.checkOnly === 'values' || this.checkOnly === 'both';
}
/** Flag determining if object keys should be checked. */
protected get checkKeys() {
return this.checkOnly === 'keys' || this.checkOnly === 'both';
}
/**
* Creates a new regex filter action
* @param checkOnly - Optional directive indicating what to check, a value, an object key, or both.
* @param replaceWith - Optional value indicating what to replace a matched value with.
*/
constructor(
public readonly checkOnly: CheckOnly = 'values',
public readonly replaceWith = FILTERED
) {}
/**
* Runs the filter
* @param val - value to filter on.
* @returns a filtered value
*/
public execute<T extends PiiData>(val: T) {
let exitPipeline = false;
if (val == null) {
exitPipeline = true;
} else if (typeof val === 'string') {
val = this.replaceValues(val) as T;
exitPipeline = val === this.replaceWith;
} else if (typeof val === 'object') {
exitPipeline = true;
// Mutate object
for (const key of Object.keys(val)) {
if (this.filterKey(key)) {
val[key] = this.replaceWith;
} else if (this.filterValue(val[key])) {
val[key] = this.replaceValues(val[key]);
}
// Encountering a non truncated or non filtered value means the pipeline must keep running.
if (exitPipeline && val[key] !== this.replaceWith) {
exitPipeline = false;
}
}
}
return { val, exitPipeline };
}
/**
* Indicates if value should be filtered
* @param val
* @returns
*/
protected filterValue(val: any) {
return this.checkValues && typeof val === 'string';
}
/**
* Let the sub classes determine how to replace values.
* @param val
*/
protected abstract replaceValues(val: string): string;
/**
* Let subclasses determine when an object's key should be filtered out.
* @param key
*/
protected abstract filterKey(key: string): boolean;
}
/**
* Uses a regular expression to scrub PII
*/
export class PiiRegexFilter extends PiiFilter implements IFilterAction {
/**
* Creates a new regex filter action
* @param regex - regular expression to use for filter
* @param checkOnly - Optional directive indicating what to check, a value, an object key, or both.
* @param replaceWith - Optional value indicating what to replace a matched value with.
*/
constructor(
public readonly regex: RegExp,
public readonly checkOnly: CheckOnly = 'values',
public readonly replaceWith = FILTERED
) {
super(checkOnly, replaceWith);
}
protected override replaceValues(val: string): string {
return val.replace(this.regex, this.replaceWith);
}
protected override filterKey(key: string): boolean {
const result = this.checkKeys && this.regex.test(key);
// Tricky edge case. The regex maybe sticky. If so, we need to reset its lastIndex so it does not
// affect a subsequent operation.
if (this.regex.sticky) {
this.regex.lastIndex = 0;
}
return result;
}
}
/**
* Makes sure that if value is a URL it doesn't have identifying info like the username or password portion of the url.
*/
export class UrlUsernamePasswordFilter extends PiiFilter {
constructor(replaceWith = FILTERED) {
super('values', replaceWith);
}
protected override replaceValues(val: string) {
const url = tryParseUrl(val);
if (url) {
if (url.username) {
url.username = this.replaceWith;
}
if (url.password) {
url.password = this.replaceWith;
}
val = decodeURI(url.toString());
}
return val;
}
protected override filterKey(): boolean {
return false;
}
}
/**
* Strips emails from data.
*/
export class EmailFilter extends PiiRegexFilter {
private readonly encode = [`'`, `"`, `=`];
private readonly decode = [`[[[']]]`, `[[["]]]`, `[[[=]]]`];
constructor(checkOnly: CheckOnly = 'values', replaceWith = FILTERED) {
super(
// RFC 5322 generalized email regex, ~ 99.99% accurate.
/(([^<>()\[\]\\.,;:\s@"]+(\.[^<>()\[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))/gim,
checkOnly,
replaceWith
);
}
protected override replaceValues(val: string) {
const url = tryParseUrl(val);
if (url) {
if (url.searchParams) {
for (const [key, value] of url.searchParams) {
url.searchParams.set(
key,
value.replace(this.regex, this.replaceWith)
);
}
}
if (url.pathname) {
url.pathname = url.pathname.replace(this.regex, this.replaceWith);
}
try {
val = decodeURI(url.toString());
} catch {
// Fallback incase the replaces made the url invalid
val = url.toString();
}
}
// Encode/decode to work around weird cases like email='foo@bar.com' which is
// technically a valid email, but ill advised and unlikely. Even if a user had
// this odd example email, the majority of the email would stripped, for example,
// email='[Filtered]' thereby eliminating PII.
this.encode.forEach((x, i) => {
val = val.replace(x, this.decode[i]);
});
val = val.replace(this.regex, this.replaceWith);
this.decode.forEach((x, i) => {
val = val.replace(x, this.encode[i]);
});
return val;
}
protected filterKey(key: string): boolean {
return false;
}
}
/** Auxillary method for safely parsing a url. If it can't be parsed returns null. */
function tryParseUrl(val: string) {
try {
return new URL(val);
} catch (_) {
return null;
}
}
/**
* Some common PII scrubbing actions
*/
export const CommonPiiActions = {
/**
* Limits object/arrays no more than 50 values.
*/
breadthFilter: new BreadthFilter(50),
/**
* Limits objects to 5 levels of depth
*/
depthFilter: new DepthFilter(5),
/**
* Makes sure the user name / password is stripped out of the url.
*/
urlUsernamePassword: new UrlUsernamePasswordFilter(),
/**
* Makes sure emails are stripped from data. Uses RFC 5322 generalized email regex, ~ 99.99% accurate.
*/
emailValues: new EmailFilter(),
/**
* Matches IP V6 values
*/
ipV6Values: new PiiRegexFilter(
/(([0-9a-fA-F]{1,4}:){7,7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]{1,}|::(ffff(:0{1,4}){0,1}:){0,1}((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9])\.){3,3}(25[0-5]|(2[0-4]|1{0,1}[0-9]){0,1}[0-9]))/gim
),
/**
* Matches IPV4 values
*/
ipV4Values: new PiiRegexFilter(
/(\b25[0-5]|\b2[0-4][0-9]|\b[01]?[0-9][0-9]?)(\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}/gim
),
/**
* Looks for keys that commonly contain PII
*/
piiKeys: new PiiRegexFilter(
/^oidc-.*|^remote-groups$|^uid$|^email_?|^ip_?|^user$|^user_?(id|name)$/i,
'keys'
),
/**
* Matches uid, session, oauth and other common tokens which we would prefer not to include in Sentry reports.
*/
tokenValues: new PiiRegexFilter(/[a-fA-F0-9]{32,}|[a-fA-F0-9]{64,}/gim),
};

Просмотреть файл

@ -0,0 +1,355 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { ErrorEvent } from '@sentry/types';
import { SQS } from 'aws-sdk';
import { Logger } from '../sentry.types';
import { IFilterAction, PiiData } from '../models/pii';
import {
CommonPiiActions,
FILTERED,
PiiRegexFilter,
TRUNCATED,
} from './filter-actions';
import { FilterBase, SentryPiiFilter, SqsMessageFilter } from './filters';
describe('pii-filters', () => {
describe('SentryMessageFilter', () => {
const sentryFilter = new SentryPiiFilter([
CommonPiiActions.breadthFilter,
CommonPiiActions.depthFilter,
CommonPiiActions.urlUsernamePassword,
CommonPiiActions.emailValues,
CommonPiiActions.piiKeys,
CommonPiiActions.tokenValues,
CommonPiiActions.ipV4Values,
CommonPiiActions.ipV6Values,
new PiiRegexFilter(/foo/gi),
]);
it('filters empty event', () => {
let event: ErrorEvent = { type: undefined };
event = sentryFilter.filter(event);
expect(event).toEqual({ type: undefined });
});
it('filters event', () => {
let event: ErrorEvent = {
message: 'A foo message.',
contexts: {
ValidationError: {
_original: {
email: `foo@bar.com`,
},
details: [
{
context: {
key: 'email',
label: 'email',
name: '[undefined]',
regex: {},
value: 'none',
},
message: `foo@bar.com fails to match email pattern`,
path: ['email'],
type: 'string.pattern.base',
},
],
type: 'ValidationError',
},
},
breadcrumbs: [
{
message: 'A foo breadcrumb',
data: {
first_name: 'foo',
last_name: 'bar',
},
},
{
message: 'A fine message',
},
],
request: {
url: 'http://me:123@foo.bar/?email=foxkey@mozilla.com&uid=12345678123456781234567812345678',
query_string: {
email: 'foo',
uid: 'bar',
},
cookies: {
user: 'foo:bar',
},
env: {
key: '--foo',
},
headers: {
foo: 'a foo header',
bar: 'a foo bar bar header',
'oidc-claim': 'claim1',
},
data: {
info: {
email: 'foxkeh@mozilla.com',
uid: '12345678123456781234567812345678',
},
time: new Date(0).getTime(),
},
},
exception: {
values: [
{
value:
'Foo bar! A user with email foxkeh@mozilla.clom and ip 127.0.0.1 encountered an err.',
},
],
},
extra: {
meta: {
email: 'foo@bar.com',
},
foo: Array(51).fill('bar'),
l1: {
l2: {
l3: {
l4: {
l5: {
l6: {
l7: {
l8: {
l9: {
l10: 'bar',
},
},
},
},
},
},
},
},
},
},
user: {
meta: {
email: 'foo@bar.com',
},
id: 'foo123',
ip_address: '127.0.0.1',
email: 'foo@bar.com',
username: 'foo.bar',
},
type: undefined,
spans: undefined, // Not testing, let's be careful not put PII in spans,
measurements: undefined, // NA, just numbers
debug_meta: undefined, // NA, image data
sdkProcessingMetadata: undefined, // NA, not used
};
event = sentryFilter.filter(event);
expect(event).toEqual({
message: `A ${FILTERED} message.`,
contexts: {
ValidationError: {
_original: {
email: '[Filtered]',
},
details: [
{
context: {
key: 'email',
label: 'email',
name: '[undefined]',
regex: {},
value: 'none',
},
message: '[Filtered] fails to match email pattern',
path: ['email'],
type: 'string.pattern.base',
},
],
type: 'ValidationError',
},
},
breadcrumbs: [
{
message: `A ${FILTERED} breadcrumb`,
data: {
first_name: FILTERED,
last_name: 'bar',
},
},
{
message: 'A fine message',
},
],
request: {
url: `http://${FILTERED}:${FILTERED}@${FILTERED}.bar/?email=${FILTERED}&uid=${FILTERED}`,
query_string: {
email: FILTERED,
uid: FILTERED,
},
cookies: {
user: FILTERED,
},
env: {
key: `--${FILTERED}`,
},
headers: {
foo: `a ${FILTERED} header`,
bar: `a ${FILTERED} bar bar header`,
'oidc-claim': `${FILTERED}`,
},
data: {
info: {
email: `${FILTERED}`,
uid: `${FILTERED}`,
},
time: new Date(0).getTime(),
},
},
exception: {
values: [
{
value: `${FILTERED} bar! A user with email ${FILTERED} and ip ${FILTERED} encountered an err.`,
},
],
},
extra: {
meta: {
email: FILTERED,
},
foo: [...Array(50).fill('bar'), `${TRUNCATED}:1`],
l1: {
l2: {
l3: {
l4: {
l5: {
l6: TRUNCATED,
},
},
},
},
},
},
user: {
meta: {
email: FILTERED,
},
id: `${FILTERED}123`,
ip_address: FILTERED,
email: FILTERED,
username: FILTERED,
},
type: undefined,
spans: undefined, // Not testing, let's be careful not put PII in spans,
measurements: undefined, // NA, just numbers
debug_meta: undefined, // NA, image data
sdkProcessingMetadata: undefined, // NA, not used
});
});
});
describe('SqsMessageFilter', () => {
const sqsFilter = new SqsMessageFilter([new PiiRegexFilter(/foo/gi)]);
it('filters body', () => {
let msg = { Body: 'A message with foo in it.' } as SQS.Message;
msg = sqsFilter.filter(msg);
expect(msg).toEqual({
Body: `A message with ${FILTERED} in it.`,
});
});
});
describe('Deals with Bad Filter', () => {
const mockLogger: Logger = {
error: jest.fn(),
warn: jest.fn(),
info: jest.fn(),
debug: jest.fn(),
};
afterEach(() => {
jest.restoreAllMocks();
});
class BadAction implements IFilterAction {
execute<T extends PiiData>(
val: T,
depth?: number
): { val: T; exitPipeline: boolean } {
throw new Error('Boom');
}
}
class BadFilter extends FilterBase {
constructor(logger: Logger) {
super([new BadAction()], logger);
}
filter(data: any): any {
return this.applyFilters(data);
}
}
it('handles errors and logs them', () => {
const badFilter = new BadFilter(mockLogger);
badFilter.filter({ foo: 'bar' });
expect(mockLogger.error).toBeCalled();
});
});
describe('Short Circuits', () => {
class ShortCircuit implements IFilterAction {
execute<T extends PiiData>(val: T, depth?: number) {
if (typeof val === 'string') {
val = FILTERED as T;
} else if (typeof val === 'object') {
for (const key in val) {
val[key] = FILTERED;
}
}
return { val, exitPipeline: true };
}
}
const shortCircuit = new ShortCircuit();
const noAction = {
execute: jest.fn(),
};
const sentryFilter = new SentryPiiFilter([
shortCircuit,
noAction as IFilterAction,
]);
afterEach(() => {
jest.restoreAllMocks();
});
it('shorts circuits', () => {
// The fact this runs with out error, indicates badAction was never invoked
const event = sentryFilter.filter({
type: undefined,
request: {
url: 'http://foo.bar',
query_string: {
foo: 'bar',
},
headers: {
foo: 'bar',
},
data: {
info: {
foo: 'bar',
},
time: new Date(0).getTime(),
},
},
});
expect(noAction.execute).toHaveBeenCalledTimes(0);
expect(event).toBeDefined();
});
});
});

Просмотреть файл

@ -0,0 +1,194 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import * as Sentry from '@sentry/node';
import { ErrorEvent } from '@sentry/types';
import { SQS } from 'aws-sdk';
import { IFilterAction, PiiData } from '../models/pii';
import { Logger } from '../sentry.types';
/**
* Base class for all filters
*/
export abstract class FilterBase {
constructor(
protected readonly actions: IFilterAction[],
protected readonly logger?: Logger
) {}
/**
* Applies filters to object and drills down into object
* @param val - Value to drill into
* @param depth - the current depth in the object
* @param maxDepth - depth at which to give up
* @returns
*/
applyFilters<T extends PiiData>(val: T, depth = 1, maxDepth = 10): T {
if (depth < maxDepth) {
for (const x of this.actions) {
try {
const result = x.execute(val, depth);
val = result.val;
// Exit pipeline early if value is not longer actionable.
if (result.exitPipeline) {
break;
}
} catch (err) {
this.logger?.error('sentry.filter.error', { err });
}
}
if (val != null && typeof val === 'object') {
Object.values(val).forEach((x) => {
this.applyFilters(x, depth + 1, maxDepth);
});
}
}
return val;
}
abstract filter(data: PiiData): PiiData;
}
/**
* Defacto Sentry Event Filter. Can be extended and customized as needed.
*/
export class SentryPiiFilter extends FilterBase {
/**
* Creates a new PII Filter for sentry data
* @param actions - Set of filters to apply
*/
constructor(actions: IFilterAction[]) {
super(actions);
}
/**
* Filter PII from all known sentry fields
*/
public filter(event: ErrorEvent) {
// Target key parts of sentry event structure
this.scrubMessage(event)
.scrubContext(event)
.scrubBreadCrumbs(event)
.scrubRequest(event)
.scrubTags(event)
.scrubException(event)
.scrubExtra(event)
.scrubUser(event);
return event;
}
protected scrubMessage(event: Sentry.Event) {
if (event.message) {
event.message = this.applyFilters(event.message);
}
return this;
}
protected scrubBreadCrumbs(event: Sentry.Event) {
for (const bc of event.breadcrumbs || []) {
if (bc.message) {
bc.message = this.applyFilters(bc.message);
}
if (bc.data) {
bc.data = this.applyFilters(bc.data);
}
}
return this;
}
protected scrubRequest(event: Sentry.Event) {
if (event.request?.headers) {
event.request.headers = this.applyFilters(event.request.headers);
}
if (event.request?.data) {
event.request.data = this.applyFilters(event.request.data);
}
if (event.request?.query_string) {
event.request.query_string = this.applyFilters(
event.request.query_string
);
}
if (event.request?.env) {
event.request.env = this.applyFilters(event.request.env);
}
if (event.request?.url) {
event.request.url = this.applyFilters(event.request.url);
}
if (event.request?.cookies) {
event.request.cookies = this.applyFilters(event.request.cookies);
}
return this;
}
protected scrubTags(event: Sentry.Event) {
if (typeof event.tags?.url === 'string') {
event.tags.url = this.applyFilters(event.tags.url);
}
return this;
}
protected scrubException(event: Sentry.Event) {
if (event.exception) {
event.exception = this.applyFilters(event.exception);
}
return this;
}
protected scrubExtra(event: Sentry.Event) {
if (event.extra) {
event.extra = this.applyFilters(event.extra);
}
return this;
}
protected scrubUser(event: Sentry.Event) {
if (event.user) {
event.user = this.applyFilters(event.user);
}
return this;
}
protected scrubContext(event: Sentry.Event) {
if (event.contexts) {
event.contexts = this.applyFilters(event.contexts);
}
return this;
}
}
/**
* Scrubs PII from SQS Messages
*/
export class SqsMessageFilter extends FilterBase {
/**
* Create a new SqsMessageFilter
* @param actions
*/
constructor(actions: IFilterAction[]) {
super(actions);
}
/**
* Filter Body of sqs messages
*/
public filter(event: SQS.Message) {
this.filterBody(event);
return event;
}
protected filterBody(event: SQS.Message) {
event.Body = this.applyFilters(event.Body);
return this;
}
}

Просмотреть файл

@ -0,0 +1,60 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import * as Sentry from '@sentry/node';
import { ValidationError } from 'joi';
/**
* Format a Stripe product/plan metadata validation error message for
* Sentry to include as much detail as possible about what metadata
* failed validation and in what way.
*
* @param {string} planId
* @param {string | ValidationError} error
*/
export function formatMetadataValidationErrorMessage(
planId: string,
error: ValidationError
) {
let msg = `${planId} metadata invalid:`;
if (typeof error === 'string') {
msg = `${msg} ${error}`;
} else {
msg = `${msg}${error.details
.map(({ message }) => ` ${message};`)
.join('')}`;
}
return msg;
}
/**
* Report a validation error to Sentry with validation details.
*
* @param {Pick<Hub, 'withScope' | 'captureMessage'>} sentry - Current sentry instance. Note, that this subtype is being
* used instead of directly accessing the sentry instance inorder to be context agnostic.
* @param {*} message
* @param {string | ValidationError} ValidationError error
*/
export function reportValidationError(
message: any,
error: ValidationError | string
) {
const details: any = {};
if (typeof error === 'string') {
details.error = error;
} else {
for (const errorItem of error.details) {
const key = errorItem.path.join('.');
details[key] = {
message: errorItem.message,
type: errorItem.type,
};
}
}
Sentry.withScope((scope) => {
scope.setContext('validationError', details);
Sentry.captureMessage(message, 'error');
});
}

Просмотреть файл

@ -0,0 +1,73 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import * as uuid from 'uuid';
import { FILTERED } from './pii/filter-actions';
import { filterObject } from './reporting';
describe('filterObject', () => {
it('should be defined', () => {
expect(filterObject).toBeDefined();
});
// Test Sentry QueryParams filtering types
it('should filter array of key/value arrays', () => {
const input = {
type: undefined,
extra: {
foo: uuid.v4().replace(/-/g, ''),
baz: uuid.v4().replace(/-/g, ''),
bar: 'fred',
},
};
const expected = {
extra: {
foo: FILTERED,
baz: FILTERED,
bar: 'fred',
},
};
const output = filterObject(input);
expect(output).toEqual(expected);
});
it('should filter an object of key/value pairs', () => {
const input = {
type: undefined,
extra: {
foo: uuid.v4().replace(/-/g, ''),
baz: uuid.v4().replace(/-/g, ''),
bar: 'fred',
},
};
const expected = {
extra: {
foo: FILTERED,
baz: FILTERED,
bar: 'fred',
},
};
const output = filterObject(input);
expect(output).toEqual(expected);
});
it('should skip nested arrays that are not valid key/value arrays', () => {
const input = {
type: undefined,
extra: {
foo: uuid.v4().replace(/-/g, ''),
bar: 'fred',
fizz: ['buzz', 'parrot'],
},
};
const expected = {
extra: {
foo: FILTERED,
bar: 'fred',
fizz: ['buzz', 'parrot'],
},
};
const output = filterObject(input);
expect(output).toEqual(expected);
});
});

Просмотреть файл

@ -0,0 +1,164 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { ExecutionContext } from '@nestjs/common';
import { ApolloServerErrorCode } from '@apollo/server/errors';
import { GraphQLError } from 'graphql';
import { GqlContextType, GqlExecutionContext } from '@nestjs/graphql';
import * as Sentry from '@sentry/node';
import { ErrorEvent } from '@sentry/types';
import { SQS } from 'aws-sdk';
import { Request } from 'express';
import { CommonPiiActions } from './pii/filter-actions';
import { SentryPiiFilter, SqsMessageFilter } from './pii/filters';
const piiFilter = new SentryPiiFilter([
CommonPiiActions.breadthFilter,
CommonPiiActions.depthFilter,
CommonPiiActions.piiKeys,
CommonPiiActions.emailValues,
CommonPiiActions.tokenValues,
CommonPiiActions.ipV4Values,
CommonPiiActions.ipV6Values,
CommonPiiActions.urlUsernamePassword,
]);
const sqsMessageFilter = new SqsMessageFilter([
CommonPiiActions.emailValues,
CommonPiiActions.tokenValues,
]);
export interface ExtraContext {
name: string;
fieldData: Record<string, string>;
}
/** Adds fxa.name to data.tags */
export function tagFxaName(data: any, name?: string) {
data.tags = data.tags || {};
data.tags['fxa.name'] = name || 'unknown';
return data;
}
/**
* Determine if an error is an ApolloError.
* Prior to GQL 16.8 and apollo-server 4.9.3, we used ApolloError from apollo-server.
* Now, we populate fields on GraphQL error to mimic the previous state of ApolloError.
*/
export function isApolloError(err: Error): boolean {
if (err instanceof GraphQLError) {
const code = err.extensions?.code;
if (typeof code === 'string') {
return Object.keys(ApolloServerErrorCode).includes(code);
}
}
return false;
}
export function isOriginallyHttpError(
error: Error & { originalError?: { status: number } }
): boolean {
return typeof error?.originalError?.status === 'number';
}
/**
* Filters all of an objects string properties to remove tokens.
*
* @param event Sentry ErrorEvent
*/
export function filterObject(event: ErrorEvent) {
return piiFilter.filter(event);
}
/**
* Filter potential PII from a sentry event.
*
* - Limits depth data beyond 5 levels
* - Filters out pii keys, See CommonPiiActions.piiKeys for more details
* - Filters out strings that look like emails addresses
* - Filters out strings that look like tokens value (32 char length alphanumeric values)
* - Filters out strings that look like ip addresses (v4/v6)
* - Filters out urls with user name / password data
* @param event A sentry event
* @returns a sanitized sentry event
*/
export function filterSentryEvent(
event: ErrorEvent,
_hint: unknown
): ErrorEvent {
return piiFilter.filter(event);
}
/**
* Capture a SQS Error to Sentry with additional context.
*
* @param err Error object to capture.
* @param message SQS Message to include with error.
*/
export function captureSqsError(err: Error, message?: SQS.Message): void {
Sentry.withScope((scope) => {
if (message?.Body) {
message = sqsMessageFilter.filter(message);
scope.setContext('SQS Message', message as Record<string, unknown>);
}
Sentry.captureException(err);
});
}
/**
* Report an exception with request and additional optional context objects.
*
* @param exception
* @param excContexts List of additional exception context objects to capture.
* @param request A request object if available.
*/
export function reportRequestException(
exception: Error & { reported?: boolean; status?: number; response?: any },
excContexts: ExtraContext[] = [],
request?: Request
) {
// Don't report already reported exceptions
if (exception.reported) {
return;
}
Sentry.withScope((scope: Sentry.Scope) => {
scope.addEventProcessor((event: Sentry.Event) => {
if (request) {
const sentryEvent = Sentry.Handlers.parseRequest(event, request);
sentryEvent.level = 'error';
return sentryEvent;
}
return null;
});
for (const ctx of excContexts) {
scope.setContext(ctx.name, ctx.fieldData);
}
Sentry.captureException(exception);
exception.reported = true;
});
}
export function processException(context: ExecutionContext, exception: Error) {
// First determine what type of a request this is
let request: Request | undefined;
let gqlExec: GqlExecutionContext | undefined;
if (context.getType() === 'http') {
request = context.switchToHttp().getRequest();
} else if (context.getType<GqlContextType>() === 'graphql') {
gqlExec = GqlExecutionContext.create(context);
request = gqlExec.getContext().req;
}
const excContexts: ExtraContext[] = [];
if (gqlExec) {
const info = gqlExec.getInfo();
excContexts.push({
name: 'graphql',
fieldData: { fieldName: info.fieldName, path: info.path },
});
}
reportRequestException(exception, excContexts, request);
}

Просмотреть файл

@ -0,0 +1,10 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
export type Logger = {
error: (type: string, data?: unknown) => void;
debug: (type: string, data?: unknown) => void;
info: (type: string, data?: unknown) => void;
warn: (type: string, data?: unknown) => void;
};

Просмотреть файл

@ -0,0 +1,16 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"module": "commonjs"
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}

Просмотреть файл

@ -0,0 +1,11 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"module": "commonjs",
"outDir": "../../../dist/out-tsc",
"declaration": true,
"types": ["node"]
},
"exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"],
"include": ["src/**/*.ts"]
}

Просмотреть файл

@ -0,0 +1,14 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../dist/out-tsc",
"module": "commonjs",
"types": ["jest", "node"]
},
"include": [
"jest.config.ts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.d.ts"
]
}

10
nx.json
Просмотреть файл

@ -197,5 +197,13 @@
}
}
},
"neverConnectToCloud": true
"neverConnectToCloud": true,
"plugins": [
{
"plugin": "@nx/eslint/plugin",
"options": {
"targetName": "lint"
}
}
]
}

Просмотреть файл

@ -135,6 +135,29 @@ records for the given user when receiving this event.
"https://schemas.accounts.firefox.com/event/delete-user": {}
}
### Metrics Opt Out
Sent when a user opts out of metrics / data collection from their Mozilla Accounts settings page.
RPs should stop reporting metrics for this user. Note, that when a user creates an account, metrics
collection is enabled by default.
- Event Identifier
- `https://schemas.accounts.firefox.com/event/metrics-opt-out`
- Event Payload
- [Metrics Opt Out Event Identifier]
- `{}`
### Metrics Opt In
Sent when a user opts back into metrics / data collection from Firefox Accounts their Mozilla Accounts settings page.
RPs can start reporting metrics for this user again.
- Event Identifier
- `https://schemas.accounts.firefox.com/event/metrics-opt-in`
- Event Payload
- [Metrics Opt In Event Identifier]
- `{}`
## Deployment
### Metrics

Просмотреть файл

@ -11,8 +11,9 @@ import {
PROFILE_CHANGE_EVENT,
SUBSCRIPTION_UPDATE_EVENT,
DELETE_EVENT,
METRICS_CHANGE_EVENT,
} from '../queueworker/sqs.dto';
import { PROFILE_EVENT_ID } from './set.interface';
import { METRICS_CHANGE_EVENT_ID, PROFILE_EVENT_ID } from './set.interface';
const TEST_KEY = {
d: 'nvfTzcMqVr8fa-b3IIFBk0J69sZQsyhKc3jYN5pPG7FdJyA-D5aPNv5zsF64JxNJetAS44cAsGAKN3Kh7LfjvLCtV56Ckg2tkBMn3GrbhE1BX6ObYvMuOBz5FJ9GmTOqSCxotAFRbR6AOBd5PCw--Rls4MylX393TFg6jJTGLkuYGuGHf8ILWyb17hbN0iyT9hME-cgLW1uc_u7oZ0vK9IxGPTblQhr82RBPQDTvZTM4s1wYiXzbJNrI_RGTAhdbwXuoXKiBN4XL0YRDKT0ENVqQLMiBwfdT3sW-M0L6kIv-L8qX3RIhbM3WA_a_LjTOM3WwRcNanSGiAeJLHwE5cQ',
@ -62,6 +63,7 @@ describe('JwtsetService', () => {
password: [PASSWORD_CHANGE_EVENT, 'generatePasswordSET'],
profile: [PROFILE_CHANGE_EVENT, 'generateProfileSET'],
delete: [DELETE_EVENT, 'generateDeleteSET'],
metrics: [METRICS_CHANGE_EVENT, 'generateMetricsChangeSET'],
};
async function checkSet(event: string, method: string) {
@ -104,5 +106,20 @@ describe('JwtsetService', () => {
expect(payload.sub).toBe('uid1234');
expect(payload.iss).toBe('test');
});
it('metrics change SET', async () => {
const event = {
clientId: TEST_CLIENT_ID,
event: METRICS_CHANGE_EVENT,
uid: 'uid1234',
enabled: false,
};
const token = await service.generateMetricsChangeSET(event);
const payload = await PUBLIC_JWT.verify(token);
expect(payload.aud).toBe(TEST_CLIENT_ID);
expect(payload.sub).toBe('uid1234');
expect(payload.iss).toBe('test');
expect(payload.events[METRICS_CHANGE_EVENT_ID].enabled).toBe(false);
});
});
});

Просмотреть файл

@ -116,8 +116,10 @@ export class JwtsetService {
uid: delEvent.uid,
});
}
public generateAppleMigrationSET(appleMigrationEvent: set.appleMigrationEvent): Promise<string> {
public generateAppleMigrationSET(
appleMigrationEvent: set.appleMigrationEvent
): Promise<string> {
return this.generateSET({
uid: appleMigrationEvent.uid,
clientId: appleMigrationEvent.clientId,
@ -129,8 +131,20 @@ export class JwtsetService {
success: appleMigrationEvent.success,
err: appleMigrationEvent.err,
uid: appleMigrationEvent.uid,
}
}
},
},
});
}
public generateMetricsChangeSET(event: set.metricsChangeEvent) {
return this.generateSET({
uid: event.uid,
clientId: event.clientId,
events: {
[set.METRICS_CHANGE_EVENT_ID]: {
enabled: event.enabled,
},
},
});
}
}

Просмотреть файл

@ -1,6 +1,7 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
// SET Event identifiers
export const DELETE_EVENT_ID =
'https://schemas.accounts.firefox.com/event/delete-user';
@ -10,10 +11,10 @@ export const PROFILE_EVENT_ID =
'https://schemas.accounts.firefox.com/event/profile-change';
export const SUBSCRIPTION_STATE_EVENT_ID =
'https://schemas.accounts.firefox.com/event/subscription-state-change';
export const APPLE_USER_MIGRATION_ID =
'https://schemas.accounts.firefox.com/event/apple-user-migration';
'https://schemas.accounts.firefox.com/event/apple-user-migration';
export const METRICS_CHANGE_EVENT_ID =
'https://schemas.accounts.firefox.com/event/metrics-change';
export type deleteEvent = {
clientId: string;
@ -59,3 +60,9 @@ export type appleMigrationEvent = {
success: boolean;
err: string;
};
export type metricsChangeEvent = {
uid: string;
clientId: string;
enabled: boolean;
};

Просмотреть файл

@ -71,6 +71,16 @@ const createValidPasswordMessage = (): string => {
).toString('base64');
};
const createValidMetricsChangeMessage = (): string => {
return Buffer.from(
JSON.stringify({
event: dto.METRICS_CHANGE_EVENT,
uid: 'uid1234',
enabled: false,
})
).toString('base64');
};
describe('PubsubProxy Controller', () => {
let controller: PubsubProxyController;
let jwtset: any;
@ -91,6 +101,7 @@ describe('PubsubProxy Controller', () => {
generatePasswordSET: jest.fn().mockResolvedValue(TEST_TOKEN),
generateProfileSET: jest.fn().mockResolvedValue(TEST_TOKEN),
generateSubscriptionSET: jest.fn().mockResolvedValue(TEST_TOKEN),
generateMetricsChangeSET: jest.fn().mockResolvedValue(TEST_TOKEN),
};
logger = { debug: jest.fn(), error: jest.fn() };
const MockMetrics: Provider = {
@ -179,6 +190,10 @@ describe('PubsubProxy Controller', () => {
delete: [createValidDeleteMessage, 'generateDeleteSET'],
password: [createValidPasswordMessage, 'generatePasswordSET'],
profile: [createValidProfileMessage, 'generateProfileSET'],
metricsChange: [
createValidMetricsChangeMessage,
'generateMetricsChangeSET',
],
};
async function notifiesSuccessfully(

Просмотреть файл

@ -210,6 +210,13 @@ export class PubsubProxyController {
err: message.error,
});
}
case dto.METRICS_CHANGE_EVENT: {
return await this.jwtset.generateMetricsChangeSET({
clientId,
uid: message.uid,
enabled: message.enabled,
});
}
default:
throw Error(`Invalid event: ${message.event}`);
}

Просмотреть файл

@ -51,6 +51,12 @@ const baseSubscriptionUpdateMessage = {
productName: undefined,
};
const baseMetricsChangeMessage = {
...baseMessage,
event: 'metricsChange',
enabled: true,
};
const baseDeleteMessage = {
...baseMessage,
event: 'delete',
@ -300,6 +306,7 @@ describe('QueueworkerService', () => {
'primary email message': basePrimaryEmailMessage,
'profile change message': baseProfileMessage,
'subscription message': baseSubscriptionUpdateMessage,
'metrics change message': baseMetricsChangeMessage,
};
// Ensure that all our message types can be handled without error.

Просмотреть файл

@ -176,7 +176,8 @@ export class QueueworkerService
| dto.deleteSchema
| dto.profileSchema
| dto.passwordSchema
| dto.appleUserMigrationSchema,
| dto.appleUserMigrationSchema
| dto.metricsChangeSchema,
eventType: string
) {
this.metrics.increment('message.type', { eventType });
@ -354,6 +355,10 @@ export class QueueworkerService
await this.handleAppleUserMigrationEvent(message);
break;
}
case dto.METRICS_CHANGE_EVENT: {
await this.handleMessageFanout(message, 'metricsOptIn');
break;
}
default:
unhandledEventType(message);
}

Просмотреть файл

@ -14,6 +14,7 @@ export type ServiceNotification =
| dto.profileSchema
| dto.subscriptionUpdateSchema
| dto.appleUserMigrationSchema
| dto.metricsChangeSchema
| undefined;
interface SchemaTable {
@ -30,6 +31,7 @@ const eventSchemas = {
[dto.PASSWORD_CHANGE_EVENT]: dto.PASSWORD_CHANGE_SCHEMA,
[dto.PASSWORD_RESET_EVENT]: dto.PASSWORD_CHANGE_SCHEMA,
[dto.APPLE_USER_MIGRATION_EVENT]: dto.APPLE_USER_MIGRATION_SCHEMA,
[dto.METRICS_CHANGE_EVENT]: dto.METRICS_CHANGE_SCHEMA,
};
/**

Просмотреть файл

@ -2,6 +2,7 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import joi from 'joi';
import { METRICS_CHANGE_EVENT_ID } from '../jwtset/set.interface';
// Event strings
export const DELETE_EVENT = 'delete';
@ -12,6 +13,7 @@ export const PRIMARY_EMAIL_EVENT = 'primaryEmailChanged';
export const PROFILE_CHANGE_EVENT = 'profileDataChange';
export const SUBSCRIPTION_UPDATE_EVENT = 'subscription:update';
export const APPLE_USER_MIGRATION_EVENT = 'appleUserMigration';
export const METRICS_CHANGE_EVENT = 'metricsChange';
// Message schemas
export const CLIENT_ID = joi.string().regex(/[a-z0-9]{16}/);
@ -93,16 +95,27 @@ export const PROFILE_CHANGE_SCHEMA = joi
.required();
export const APPLE_USER_MIGRATION_SCHEMA = joi
.object()
.keys({
event: joi.string().valid(APPLE_USER_MIGRATION_EVENT),
timestamp: joi.number().optional(),
ts: joi.number().required(),
uid: joi.string().required(),
})
.unknown(true)
.required();
.object()
.keys({
event: joi.string().valid(APPLE_USER_MIGRATION_EVENT),
timestamp: joi.number().optional(),
ts: joi.number().required(),
uid: joi.string().required(),
})
.unknown(true)
.required();
export const METRICS_CHANGE_SCHEMA = joi
.object()
.keys({
event: joi.string().valid(METRICS_CHANGE_EVENT),
timestamp: joi.number().optional(),
ts: joi.number().required(),
uid: joi.string().required(),
enabled: joi.boolean().required(),
})
.unknown(true)
.required();
export type deleteSchema = {
event: typeof DELETE_EVENT;
@ -160,4 +173,12 @@ export type appleUserMigrationSchema = {
transferSub: string;
success: boolean;
err: string;
}
};
export type metricsChangeSchema = {
event: typeof METRICS_CHANGE_EVENT;
timestamp?: number;
ts: number;
uid: string;
enabled: boolean;
};

Просмотреть файл

@ -35,6 +35,9 @@ module.exports = {
SENTRY_ENV: 'local',
SENTRY_DSN: process.env.SENTRY_DSN_GRAPHQL_API,
TRACING_SERVICE_NAME: 'fxa-graphql-api',
SNS_TOPIC_ARN:
'arn:aws:sns:us-east-1:100010001000:fxa-account-change-dev',
SNS_TOPIC_ENDPOINT: 'http://localhost:4100/',
},
filter_env: ['npm_'],
watch: ['src'],

Просмотреть файл

@ -3,10 +3,10 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { HealthModule } from 'fxa-shared/nestjs/health/health.module';
import { LoggerModule } from 'fxa-shared/nestjs/logger/logger.module';
import { MozLoggerService } from 'fxa-shared/nestjs/logger/logger.service';
import { MetricsFactory } from 'fxa-shared/nestjs/metrics.service';
import { SentryModule } from 'fxa-shared/nestjs/sentry/sentry.module';
import { MozLoggerService } from '@fxa/shared/mozlog';
import { StatsDFactory } from '@fxa/shared/metrics/statsd';
import { NotifierSnsFactory, NotifierService } from '@fxa/shared/notifier';
import { getVersionInfo } from 'fxa-shared/nestjs/version';
import { ApolloDriver, ApolloDriverConfig } from '@nestjs/apollo';
@ -36,8 +36,8 @@ const version = getVersionInfo(__dirname);
GqlModule,
GraphQLModule.forRootAsync<ApolloDriverConfig>({
driver: ApolloDriver,
imports: [ConfigModule, LoggerModule, SentryModule],
inject: [ConfigService, MozLoggerService],
imports: [ConfigModule],
inject: [ConfigService],
useFactory: GraphQLConfigFactory,
}),
HealthModule.forRootAsync({
@ -48,19 +48,14 @@ const version = getVersionInfo(__dirname);
extraHealthData: () => db.dbHealthCheck(),
}),
}),
LoggerModule,
SentryModule.forRootAsync({
imports: [ConfigModule, LoggerModule],
inject: [ConfigService, MozLoggerService],
useFactory: (configService: ConfigService<AppConfig>) => ({
sentryConfig: {
sentry: configService.get('sentry'),
version: version.version,
},
}),
}),
],
controllers: [],
providers: [MetricsFactory, ComplexityPlugin],
providers: [
StatsDFactory,
NotifierSnsFactory,
NotifierService,
MozLoggerService,
ComplexityPlugin,
],
})
export class AppModule {}

Просмотреть файл

@ -2,7 +2,6 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { Module } from '@nestjs/common';
import { AuthClientFactory, AuthClientService } from './auth-client.service';
import { ProfileClientService } from './profile-client.service';
import { LegalService } from './legal.service';

Просмотреть файл

@ -7,7 +7,7 @@ import { getComplexity, simpleEstimator } from 'graphql-query-complexity';
import { GraphQLError } from 'graphql';
import { GraphQLSchemaHost } from '@nestjs/graphql';
import { Inject } from '@nestjs/common';
import { MozLoggerService } from 'fxa-shared/nestjs/logger/logger.service';
import { MozLoggerService } from '@fxa/shared/mozlog';
const maxComplexity = 1000;

Просмотреть файл

@ -278,6 +278,22 @@ const conf = convict({
// Note: This format is a number because the value needs to be in seconds
format: Number,
},
notifier: {
sns: {
snsTopicArn: {
doc: 'Amazon SNS topic on which to send account event notifications. Set to "disabled" to turn off the notifier',
format: String,
env: 'SNS_TOPIC_ARN',
default: '',
},
snsTopicEndpoint: {
doc: 'Amazon SNS topic endpoint',
format: String,
env: 'SNS_TOPIC_ENDPOINT',
default: undefined,
},
},
},
});
// handle configuration files. you can specify a CSV list of configuration

Просмотреть файл

@ -1,9 +1,10 @@
import { Module } from '@nestjs/common';
import { MetricsFactory } from 'fxa-shared/nestjs/metrics.service';
import { DatabaseService } from './database.service';
import { MozLoggerService } from '@fxa/shared/mozlog';
import { StatsDFactory } from '@fxa/shared/metrics/statsd';
@Module({
providers: [DatabaseService, MetricsFactory],
providers: [DatabaseService, MozLoggerService, StatsDFactory],
exports: [DatabaseService],
})
export class DatabaseModule {}

Просмотреть файл

@ -7,7 +7,7 @@ import { DatabaseService } from './database.service';
import { Provider } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Account } from 'fxa-shared/db/models/auth';
import { MozLoggerService } from 'fxa-shared/nestjs/logger/logger.service';
import { MozLoggerService } from '@fxa/shared/mozlog';
describe('#integration - DatabaseService', () => {
let service: DatabaseService;

Просмотреть файл

@ -7,7 +7,7 @@ import {
setupProfileDatabase,
} from 'fxa-shared/db';
import { Account } from 'fxa-shared/db/models/auth';
import { MozLoggerService } from 'fxa-shared/nestjs/logger/logger.service';
import { MozLoggerService } from '@fxa/shared/mozlog';
import { StatsD } from 'hot-shots';
import { Knex } from 'knex';
@ -15,6 +15,7 @@ import { Inject, Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { AppConfig } from '../config';
import { StatsDService } from '@fxa/shared/metrics/statsd';
@Injectable()
export class DatabaseService {
@ -25,7 +26,7 @@ export class DatabaseService {
constructor(
configService: ConfigService<AppConfig>,
logger: MozLoggerService,
@Inject('METRICS') metrics: StatsD
@Inject(StatsDService) metrics: StatsD
) {
const dbConfig = configService.get('database') as AppConfig['database'];
this.authKnex = setupAuthDatabase(dbConfig.mysql.auth, logger, metrics);

Просмотреть файл

@ -1,12 +1,12 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { Provider } from '@nestjs/common';
import { Logger, Provider } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { Test, TestingModule } from '@nestjs/testing';
import { Account, SessionToken } from 'fxa-shared/db/models/auth';
import { CustomsService } from 'fxa-shared/nestjs/customs/customs.service';
import { MozLoggerService } from 'fxa-shared/nestjs/logger/logger.service';
import { MozLoggerService } from '@fxa/shared/mozlog';
import { Knex } from 'knex';
import {
@ -18,6 +18,7 @@ import {
import { AuthClientService } from '../backend/auth-client.service';
import { ProfileClientService } from '../backend/profile-client.service';
import { AccountResolver } from './account.resolver';
import { NotifierService, NotifierSnsService } from '@fxa/shared/notifier';
let USER_1: any;
let SESSION_1: any;
@ -28,6 +29,8 @@ describe('#integration - AccountResolver', () => {
let knex: Knex;
let authClient: any;
let profileClient: any;
let notifierSnsService: any;
let notifierService: any;
beforeAll(async () => {
knex = await testDatabaseSetup();
@ -45,6 +48,9 @@ describe('#integration - AccountResolver', () => {
info: jest.fn(),
warn: jest.fn(),
};
notifierService = {
send: jest.fn(),
};
const MockMozLogger: Provider = {
provide: MozLoggerService,
useValue: logger,
@ -53,6 +59,18 @@ describe('#integration - AccountResolver', () => {
provide: 'METRICS',
useFactory: () => undefined,
};
const MockLogger: Provider = {
provide: Logger,
useValue: logger,
};
const MockNotifierSns: Provider = {
provide: NotifierSnsService,
useValue: notifierSnsService,
};
const MockNotifierService: Provider = {
provide: NotifierService,
useValue: notifierService,
};
authClient = {};
profileClient = {};
const module: TestingModule = await Test.createTestingModule({
@ -60,6 +78,9 @@ describe('#integration - AccountResolver', () => {
AccountResolver,
MockMozLogger,
MockMetricsFactory,
MockLogger,
MockNotifierSns,
MockNotifierService,
{ provide: CustomsService, useValue: {} },
{ provide: AuthClientService, useValue: authClient },
{ provide: ProfileClientService, useValue: profileClient },
@ -458,6 +479,12 @@ describe('#integration - AccountResolver', () => {
expect(result).toStrictEqual({
clientMutationId: 'testid',
});
expect(notifierService.send).toBeCalledWith({
event: 'metricsOptOut',
data: {
uid: USER_1.uid,
},
});
});
it('opts in', async () => {
@ -470,6 +497,23 @@ describe('#integration - AccountResolver', () => {
expect(result).toStrictEqual({
clientMutationId: 'testid',
});
expect(notifierService.send).toBeCalledWith({
event: 'metricsOptIn',
data: {
uid: USER_1.uid,
},
});
});
it('fails with bad opt in state', async () => {
await expect(
resolver.metricsOpt(USER_1.uid, {
clientMutationId: 'testid',
state: 'In' as 'in',
})
).rejects.toThrow(
'Invalid metrics opt state! State must be in or out, but recieved In.'
);
});
});

Просмотреть файл

@ -2,13 +2,10 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { Inject, UseGuards } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import {
Args,
Field,
Info,
Mutation,
ObjectType,
Parent,
Query,
ResolveField,
@ -19,11 +16,9 @@ import {
Account,
AccountOptions,
EmailBounce,
KeyFetchToken,
SessionToken,
} from 'fxa-shared/db/models/auth';
import { profileByUid, selectedAvatar } from 'fxa-shared/db/models/profile';
import { MozLoggerService } from 'fxa-shared/nestjs/logger/logger.service';
import {
ExtraContext,
reportRequestException,
@ -40,7 +35,6 @@ import { UnverifiedSessionGuard } from '../auth/unverified-session-guard';
import { GqlCustomsGuard } from '../auth/gql-customs.guard';
import { AuthClientService } from '../backend/auth-client.service';
import { ProfileClientService } from '../backend/profile-client.service';
import { AppConfig } from '../config';
import { GqlSessionToken, GqlUserId, GqlXHeaders } from '../decorators';
import {
AttachedClientDisconnectInput,
@ -94,6 +88,8 @@ import { uuidTransformer } from 'fxa-shared/db/transformers';
import { FinishedSetupAccountPayload } from './dto/payload/finished-setup-account';
import { FinishSetupInput } from './dto/input/finish-setup';
import { EmailBounceStatusPayload } from './dto/payload/email-bounce';
import { NotifierService } from '@fxa/shared/notifier';
import { MozLoggerService } from '@fxa/shared/mozlog';
function snakeToCamel(str: string) {
return str.replace(/(_\w)/g, (m: string) => m[1].toUpperCase());
@ -113,18 +109,12 @@ export function snakeToCamelObject(obj: { [key: string]: any }) {
@Resolver((of: any) => AccountType)
export class AccountResolver {
private profileServerUrl: string;
constructor(
@Inject(AuthClientService) private authAPI: AuthClient,
private notifier: NotifierService,
private profileAPI: ProfileClientService,
private log: MozLoggerService,
private configService: ConfigService<AppConfig>
) {
this.profileServerUrl = (
configService.get('profileServer') as AppConfig['profileServer']
).url;
}
private log: MozLoggerService
) {}
private shouldIncludeEmails(info: GraphQLResolveInfo): boolean {
// Introspect the query to determine if we should load the emails
@ -420,7 +410,8 @@ export class AccountResolver {
}
@Mutation((returns) => BasicPayload, {
description: 'Set the metrics opt in or out state',
description:
'Set the metrics opt in or out state, and notify RPs of the change',
})
@UseGuards(GqlAuthGuard, GqlCustomsGuard)
@CatchGatewayError
@ -430,6 +421,21 @@ export class AccountResolver {
input: MetricsOptInput
): Promise<BasicPayload> {
await Account.setMetricsOpt(uid, input.state);
if (!['in', 'out'].includes(input.state)) {
throw new Error(
`Invalid state. Expected 'in' or 'out'. But recieved: ${input.state}`
);
}
await this.notifier.send({
event: 'metricsChange',
data: {
uid,
enabled: input.state === 'in',
},
});
return { clientMutationId: input.clientMutationId };
}

Просмотреть файл

@ -10,7 +10,7 @@ describe('#payments-cart - resolvers', () => {
// import { Provider } from '@nestjs/common';
// import { Test, TestingModule } from '@nestjs/testing';
// import { MozLoggerService } from 'fxa-shared/nestjs/logger/logger.service';
// import { MozLoggerService } from '@fxa/shared/mozlog';
// import { Logger } from '@fxa/shared/log';
// import {
// CartIdInputFactory,

Просмотреть файл

@ -2,7 +2,7 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import { MozLoggerService } from 'fxa-shared/nestjs/logger/logger.service';
import { MozLoggerService } from '@fxa/shared/mozlog';
import { Cart as CartType } from './model/cart.model';
import { SetupCartInput } from './dto/input/setup-cart.input';
import { CartIdInput } from './dto/input/cart-id.input';

Просмотреть файл

@ -5,11 +5,7 @@
import { NextFunction, Request, Response } from 'express';
import { CustomsModule } from 'fxa-shared/nestjs/customs/customs.module';
import { CustomsService } from 'fxa-shared/nestjs/customs/customs.service';
import { MozLoggerService } from 'fxa-shared/nestjs/logger/logger.service';
import {
createContext,
SentryPlugin,
} from 'fxa-shared/nestjs/sentry/sentry.plugin';
import { createContext, SentryPlugin } from '@fxa/shared/sentry';
import path, { join } from 'path';
import {
@ -27,6 +23,10 @@ import { LegalResolver } from './legal.resolver';
import { SubscriptionResolver } from './subscription.resolver';
import { ClientInfoResolver } from './clientInfo.resolver';
import { SessionResolver } from './session.resolver';
import { NotifierService, NotifierSnsFactory } from '@fxa/shared/notifier';
import { StatsDFactory } from '@fxa/shared/metrics/statsd';
import { MozLoggerService } from '@fxa/shared/mozlog';
const config = Config.getProperties();
/**
@ -36,8 +36,7 @@ const config = Config.getProperties();
* @param log
*/
export const GraphQLConfigFactory = async (
configService: ConfigService<AppConfig>,
log: MozLoggerService
configService: ConfigService<AppConfig>
) => ({
allowBatchedHttpRequests: true,
path: '/graphql',
@ -53,12 +52,16 @@ export const GraphQLConfigFactory = async (
@Module({
imports: [BackendModule, CustomsModule],
providers: [
StatsDFactory,
NotifierSnsFactory,
NotifierService,
AccountResolver,
CustomsService,
SessionResolver,
LegalResolver,
ClientInfoResolver,
SubscriptionResolver,
MozLoggerService,
SentryPlugin,
],
})

Просмотреть файл

@ -4,7 +4,7 @@
import { Provider } from '@nestjs/common';
import { Test, TestingModule } from '@nestjs/testing';
import { CustomsService } from 'fxa-shared/nestjs/customs/customs.service';
import { MozLoggerService } from 'fxa-shared/nestjs/logger/logger.service';
import { MozLoggerService } from '@fxa/shared/mozlog';
import { AuthClientService } from '../backend/auth-client.service';
import { SessionResolver } from './session.resolver';

Просмотреть файл

@ -5,7 +5,7 @@ import { Inject, UseGuards } from '@nestjs/common';
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import AuthClient from 'fxa-auth-client';
import { SessionVerifiedState } from 'fxa-shared/db/models/auth/session-token';
import { MozLoggerService } from 'fxa-shared/nestjs/logger/logger.service';
import { MozLoggerService } from '@fxa/shared/mozlog';
import { GqlAuthGuard } from '../auth/gql-auth.guard';
import { GqlCustomsGuard } from '../auth/gql-customs.guard';

Просмотреть файл

@ -9,7 +9,7 @@ import './monitoring';
import bodyParser from 'body-parser';
import { Request, Response } from 'express';
import { allowlistGqlQueries } from 'fxa-shared/nestjs/gql/gql-allowlist';
import { SentryInterceptor } from 'fxa-shared/nestjs/sentry/sentry.interceptor';
import { SentryInterceptor } from '@fxa/shared/sentry';
import helmet from 'helmet';
import { NestApplicationOptions } from '@nestjs/common';

Просмотреть файл

@ -10,7 +10,10 @@
"@fxa/shared/log": ["libs/shared/log/src/index"],
"@fxa/shared/l10n": ["libs/shared/l10n/src/index"],
"@fxa/shared/db/mysql/core": ["libs/shared/db/mysql/core/src/index"],
"@fxa/shared/metrics/statsd": ["libs/shared/metrics/statsd/src/index"]
"@fxa/shared/metrics/statsd": ["libs/shared/metrics/statsd/src/index"],
"@fxa/shared/mozlog": ["libs/shared/mozlog/src/index"],
"@fxa/shared/notifier": ["libs/shared/notifier/src/index"],
"@fxa/shared/sentry": ["libs/shared/sentry/src/index"]
}
}
}

Просмотреть файл

@ -69,7 +69,7 @@ describe('pii-filter-actions', () => {
});
});
it('truncates object of size greater than maxBreadth', () => {
it('truncates object of size greater than max breadth', () => {
const filter = new BreadthFilter(1);
expect(filter.execute({ foo: '1', bar: '2', baz: '3' })).to.deep.equal({
val: {
@ -96,7 +96,7 @@ describe('pii-filter-actions', () => {
});
});
it('truncates array of size greater than maxBreadth', () => {
it('truncates array of size greater than max breadth', () => {
const filter = new BreadthFilter(1);
expect(filter.execute(['foo', 'bar', 'baz'])).to.deep.equal({
val: ['foo', `${TRUNCATED}:2`],

Просмотреть файл

@ -49,10 +49,13 @@
"@fxa/shared/error": ["libs/shared/error/src/index.ts"],
"@fxa/shared/geodb": ["libs/shared/geodb/src/index.ts"],
"@fxa/shared/l10n": ["libs/shared/l10n/src/index.ts"],
"@fxa/shared/l10n/server": ["libs/shared/l10n/src/server.ts"],
"@fxa/shared/l10n/client": ["libs/shared/l10n/src/client.ts"],
"@fxa/shared/l10n/server": ["libs/shared/l10n/src/server.ts"],
"@fxa/shared/log": ["libs/shared/log/src/index.ts"],
"@fxa/shared/metrics/statsd": ["libs/shared/metrics/statsd/src/index.ts"]
"@fxa/shared/metrics/statsd": ["libs/shared/metrics/statsd/src/index.ts"],
"@fxa/shared/mozlog": ["libs/shared/mozlog/src/index.ts"],
"@fxa/shared/notifier": ["libs/shared/notifier/src/index.ts"],
"@fxa/shared/sentry": ["libs/shared/sentry/src/index.ts"]
},
"typeRoots": [
"./types",