зеркало из https://github.com/mozilla/fxa.git
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:
Родитель
ca6c2b3f07
Коммит
8b9051f881
|
@ -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
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",
|
||||
|
|
Загрузка…
Ссылка в новой задаче