mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Echo: mechanism for collecting custom events lazily (#20365)
* Introduce Echo for collecting frontend metrics * Update public/app/core/services/echo/Echo.ts Co-Authored-By: Peter Holmberg <peterholmberg@users.noreply.github.com> * Custom meta when adding event * Rename consumer to backend * Remove buffer from Echo * Minor tweaks * Update package.json * Update public/app/app.ts * Update public/app/app.ts * Collect paint metrics when collecting tti. Remove echoBackendFactory * Update yarn.lock * Move Echo interfaces to runtime * progress on meta and echo * Collect meta analytics events * Move MetaanalyticsBackend to enterprise repo * Fixed unit tests * Removed unused type from test * Fixed issues with chunk loading (reverted index-template changes) * Restored changes * Fixed webpack prod
This commit is contained in:
parent
4b8a50e70b
commit
178bb1d3ab
@ -256,6 +256,7 @@
|
||||
"tether": "1.4.5",
|
||||
"tether-drop": "https://github.com/torkelo/drop/tarball/master",
|
||||
"tinycolor2": "1.4.1",
|
||||
"tti-polyfill": "0.2.2",
|
||||
"xss": "1.0.3"
|
||||
},
|
||||
"resolutions": {
|
||||
|
@ -1,3 +1,5 @@
|
||||
export * from './services';
|
||||
export * from './config';
|
||||
export * from './types';
|
||||
export { loadPluginCss, SystemJS } from './utils/plugin';
|
||||
export { reportMetaAnalytics } from './utils/analytics';
|
||||
|
57
packages/grafana-runtime/src/services/EchoSrv.ts
Normal file
57
packages/grafana-runtime/src/services/EchoSrv.ts
Normal file
@ -0,0 +1,57 @@
|
||||
interface SizeMeta {
|
||||
width: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface EchoMeta {
|
||||
screenSize: SizeMeta;
|
||||
windowSize: SizeMeta;
|
||||
userAgent: string;
|
||||
url?: string;
|
||||
/**
|
||||
* A unique browser session
|
||||
*/
|
||||
sessionId: string;
|
||||
userLogin: string;
|
||||
userId: number;
|
||||
userSignedIn: boolean;
|
||||
ts: number;
|
||||
}
|
||||
|
||||
export interface EchoBackend<T extends EchoEvent = any, O = any> {
|
||||
options: O;
|
||||
supportedEvents: EchoEventType[];
|
||||
flush: () => void;
|
||||
addEvent: (event: T) => void;
|
||||
}
|
||||
|
||||
export interface EchoEvent<T extends EchoEventType = any, P = any> {
|
||||
type: EchoEventType;
|
||||
payload: P;
|
||||
meta: EchoMeta;
|
||||
}
|
||||
|
||||
export enum EchoEventType {
|
||||
Performance = 'performance',
|
||||
MetaAnalytics = 'meta-analytics',
|
||||
}
|
||||
|
||||
export interface EchoSrv {
|
||||
flush(): void;
|
||||
addBackend(backend: EchoBackend): void;
|
||||
addEvent<T extends EchoEvent>(event: Omit<T, 'meta'>, meta?: {}): void;
|
||||
}
|
||||
|
||||
let singletonInstance: EchoSrv;
|
||||
|
||||
export function setEchoSrv(instance: EchoSrv) {
|
||||
singletonInstance = instance;
|
||||
}
|
||||
|
||||
export function getEchoSrv(): EchoSrv {
|
||||
return singletonInstance;
|
||||
}
|
||||
|
||||
export const registerEchoBackend = (backend: EchoBackend) => {
|
||||
getEchoSrv().addBackend(backend);
|
||||
};
|
@ -2,3 +2,4 @@ export * from './backendSrv';
|
||||
export * from './AngularLoader';
|
||||
export * from './dataSourceSrv';
|
||||
export * from './LocationSrv';
|
||||
export * from './EchoSrv';
|
||||
|
18
packages/grafana-runtime/src/types/analytics.ts
Normal file
18
packages/grafana-runtime/src/types/analytics.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import { EchoEvent, EchoEventType } from '../services/EchoSrv';
|
||||
|
||||
export interface MetaAnalyticsEventPayload {
|
||||
eventName: string;
|
||||
dashboardId?: number;
|
||||
dashboardUid?: string;
|
||||
dashboardName?: string;
|
||||
folderName?: string;
|
||||
panelId?: number;
|
||||
panelName?: string;
|
||||
datasourceName: string;
|
||||
datasourceId?: number;
|
||||
error?: string;
|
||||
duration: number;
|
||||
dataSize?: number;
|
||||
}
|
||||
|
||||
export interface MetaAnalyticsEvent extends EchoEvent<EchoEventType.MetaAnalytics, MetaAnalyticsEventPayload> {}
|
1
packages/grafana-runtime/src/types/index.ts
Normal file
1
packages/grafana-runtime/src/types/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export * from './analytics';
|
9
packages/grafana-runtime/src/utils/analytics.ts
Normal file
9
packages/grafana-runtime/src/utils/analytics.ts
Normal file
@ -0,0 +1,9 @@
|
||||
import { getEchoSrv, EchoEventType } from '../services/EchoSrv';
|
||||
import { MetaAnalyticsEvent, MetaAnalyticsEventPayload } from '../types/analytics';
|
||||
|
||||
export const reportMetaAnalytics = (payload: MetaAnalyticsEventPayload) => {
|
||||
getEchoSrv().addEvent<MetaAnalyticsEvent>({
|
||||
type: EchoEventType.MetaAnalytics,
|
||||
payload,
|
||||
});
|
||||
};
|
@ -187,7 +187,7 @@ $btn-drag-image: '../img/grab_dark.svg';
|
||||
|
||||
$navbar-btn-gicon-brightness: brightness(0.5);
|
||||
|
||||
$btn-active-box-shadow: 0px 0px 4px rgba(255,120,10,0.5);
|
||||
$btn-active-box-shadow: 0px 0px 4px rgba(255, 120, 10, 0.5);
|
||||
|
||||
// Forms
|
||||
// -------------------------
|
||||
|
@ -16,6 +16,8 @@ import 'vendor/angular-other/angular-strap';
|
||||
import $ from 'jquery';
|
||||
import angular from 'angular';
|
||||
import config from 'app/core/config';
|
||||
// @ts-ignore
|
||||
import ttiPolyfill from 'tti-polyfill';
|
||||
// @ts-ignore ignoring this for now, otherwise we would have to extend _ interface with move
|
||||
import _ from 'lodash';
|
||||
import { AppEvents, setMarkdownOptions, setLocale } from '@grafana/data';
|
||||
@ -34,6 +36,10 @@ _.move = (array: [], fromIndex: number, toIndex: number) => {
|
||||
import { coreModule, angularModules } from 'app/core/core_module';
|
||||
import { registerAngularDirectives } from 'app/core/core';
|
||||
import { setupAngularRoutes } from 'app/routes/routes';
|
||||
import { setEchoSrv, registerEchoBackend } from '@grafana/runtime';
|
||||
import { Echo } from './core/services/echo/Echo';
|
||||
import { reportPerformance } from './core/services/echo/EchoSrv';
|
||||
import { PerformanceBackend } from './core/services/echo/backends/PerformanceBackend';
|
||||
|
||||
import 'app/routes/GrafanaCtrl';
|
||||
import 'app/features/all';
|
||||
@ -163,6 +169,26 @@ export class GrafanaApp {
|
||||
importPluginModule(modulePath);
|
||||
}
|
||||
}
|
||||
|
||||
initEchoSrv() {
|
||||
setEchoSrv(new Echo({ debug: process.env.NODE_ENV === 'development' }));
|
||||
|
||||
ttiPolyfill.getFirstConsistentlyInteractive().then((tti: any) => {
|
||||
// Collecting paint metrics first
|
||||
const paintMetrics = performance.getEntriesByType('paint');
|
||||
|
||||
for (const metric of paintMetrics) {
|
||||
reportPerformance(metric.name, Math.round(metric.startTime + metric.duration));
|
||||
}
|
||||
reportPerformance('tti', tti);
|
||||
});
|
||||
|
||||
registerEchoBackend(new PerformanceBackend({}));
|
||||
|
||||
window.addEventListener('DOMContentLoaded', () => {
|
||||
reportPerformance('dcl', Math.round(performance.now()));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new GrafanaApp();
|
||||
|
@ -21,6 +21,7 @@ const setup = (propOverrides?: object) => {
|
||||
orgCount: 2,
|
||||
orgRole: '',
|
||||
orgId: 1,
|
||||
login: 'hello',
|
||||
orgName: 'Grafana',
|
||||
timezone: 'UTC',
|
||||
helpFlags1: 1,
|
||||
|
@ -9,6 +9,7 @@ export class User {
|
||||
orgRole: any;
|
||||
orgId: number;
|
||||
orgName: string;
|
||||
login: string;
|
||||
orgCount: number;
|
||||
timezone: string;
|
||||
helpFlags1: number;
|
||||
|
89
public/app/core/services/echo/Echo.ts
Normal file
89
public/app/core/services/echo/Echo.ts
Normal file
@ -0,0 +1,89 @@
|
||||
import { EchoBackend, EchoMeta, EchoEvent, EchoSrv } from '@grafana/runtime';
|
||||
import { contextSrv } from '../context_srv';
|
||||
|
||||
interface EchoConfig {
|
||||
// How often should metrics be reported
|
||||
flushInterval: number;
|
||||
// Enables debug mode
|
||||
debug: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Echo is a service for collecting events from Grafana client-app
|
||||
* It collects events, distributes them across registered backend and flushes once per configured interval
|
||||
* It's up to the registered backend to decide what to do with a given type of metric
|
||||
*/
|
||||
export class Echo implements EchoSrv {
|
||||
private config: EchoConfig = {
|
||||
flushInterval: 10000, // By default Echo flushes every 10s
|
||||
debug: false,
|
||||
};
|
||||
|
||||
private backends: EchoBackend[] = [];
|
||||
// meta data added to every event collected
|
||||
|
||||
constructor(config?: Partial<EchoConfig>) {
|
||||
this.config = {
|
||||
...this.config,
|
||||
...config,
|
||||
};
|
||||
setInterval(this.flush, this.config.flushInterval);
|
||||
}
|
||||
|
||||
logDebug = (...msg: any) => {
|
||||
if (this.config.debug) {
|
||||
// tslint:disable-next-line
|
||||
// console.debug('ECHO:', ...msg);
|
||||
}
|
||||
};
|
||||
|
||||
flush = () => {
|
||||
for (const backend of this.backends) {
|
||||
backend.flush();
|
||||
}
|
||||
};
|
||||
|
||||
addBackend = (backend: EchoBackend) => {
|
||||
this.logDebug('Adding backend', backend);
|
||||
this.backends.push(backend);
|
||||
};
|
||||
|
||||
addEvent = <T extends EchoEvent>(event: Omit<T, 'meta'>, _meta?: {}) => {
|
||||
const meta = this.getMeta();
|
||||
const _event = {
|
||||
...event,
|
||||
meta: {
|
||||
...meta,
|
||||
..._meta,
|
||||
},
|
||||
};
|
||||
|
||||
for (const backend of this.backends) {
|
||||
if (backend.supportedEvents.length === 0 || backend.supportedEvents.indexOf(_event.type) > -1) {
|
||||
backend.addEvent(_event);
|
||||
}
|
||||
}
|
||||
|
||||
this.logDebug('Adding event', _event);
|
||||
};
|
||||
|
||||
getMeta = (): EchoMeta => {
|
||||
return {
|
||||
sessionId: '',
|
||||
userId: contextSrv.user.id,
|
||||
userLogin: contextSrv.user.login,
|
||||
userSignedIn: contextSrv.user.isSignedIn,
|
||||
screenSize: {
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
},
|
||||
windowSize: {
|
||||
width: window.screen.width,
|
||||
height: window.screen.height,
|
||||
},
|
||||
userAgent: window.navigator.userAgent,
|
||||
ts: performance.now(),
|
||||
url: window.location.href,
|
||||
};
|
||||
};
|
||||
}
|
12
public/app/core/services/echo/EchoSrv.ts
Normal file
12
public/app/core/services/echo/EchoSrv.ts
Normal file
@ -0,0 +1,12 @@
|
||||
import { getEchoSrv, EchoEventType } from '@grafana/runtime';
|
||||
import { PerformanceEvent } from './backends/PerformanceBackend';
|
||||
|
||||
export const reportPerformance = (metric: string, value: number) => {
|
||||
getEchoSrv().addEvent<PerformanceEvent>({
|
||||
type: EchoEventType.Performance,
|
||||
payload: {
|
||||
metricName: metric,
|
||||
duration: value,
|
||||
},
|
||||
});
|
||||
};
|
49
public/app/core/services/echo/backends/PerformanceBackend.ts
Normal file
49
public/app/core/services/echo/backends/PerformanceBackend.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import { EchoBackend, EchoEvent, EchoEventType } from '@grafana/runtime';
|
||||
|
||||
export interface PerformanceEventPayload {
|
||||
metricName: string;
|
||||
duration: number;
|
||||
}
|
||||
|
||||
export interface PerformanceEvent extends EchoEvent<EchoEventType.Performance, PerformanceEventPayload> {}
|
||||
|
||||
export interface PerformanceBackendOptions {
|
||||
url?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Echo's performance metrics consumer
|
||||
* Reports performance metrics to given url (TODO)
|
||||
*/
|
||||
export class PerformanceBackend implements EchoBackend<PerformanceEvent, PerformanceBackendOptions> {
|
||||
private buffer: PerformanceEvent[] = [];
|
||||
supportedEvents = [EchoEventType.Performance];
|
||||
|
||||
constructor(public options: PerformanceBackendOptions) {}
|
||||
|
||||
addEvent = (e: EchoEvent) => {
|
||||
this.buffer.push(e);
|
||||
};
|
||||
|
||||
flush = () => {
|
||||
if (this.buffer.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = {
|
||||
metrics: this.buffer,
|
||||
};
|
||||
|
||||
// Currently we don have API for sending the metrics hence loging to console in dev environment
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
console.log('PerformanceBackend flushing:', result);
|
||||
}
|
||||
|
||||
this.buffer = [];
|
||||
|
||||
// TODO: Enable backend request when we have metrics API
|
||||
// if (this.options.url) {
|
||||
// getBackendSrv().post(this.options.url, result);
|
||||
// }
|
||||
};
|
||||
}
|
@ -1,22 +1,19 @@
|
||||
import { PanelQueryRunner } from './PanelQueryRunner';
|
||||
import { PanelData, DataQueryRequest, dateTime, ScopedVars } from '@grafana/data';
|
||||
import { PanelModel } from './PanelModel';
|
||||
import { DashboardModel } from './index';
|
||||
import { setEchoSrv } from '@grafana/runtime';
|
||||
import { Echo } from '../../../core/services/echo/Echo';
|
||||
|
||||
jest.mock('app/core/services/backend_srv');
|
||||
|
||||
// Defined within setup functions
|
||||
const panelsForCurrentDashboardMock: { [key: number]: PanelModel } = {};
|
||||
const dashboardModel = new DashboardModel({
|
||||
panels: [{ id: 1, type: 'graph' }],
|
||||
});
|
||||
|
||||
jest.mock('app/features/dashboard/services/DashboardSrv', () => ({
|
||||
getDashboardSrv: () => {
|
||||
return {
|
||||
getCurrent: () => {
|
||||
return {
|
||||
getPanelById: (id: number) => {
|
||||
return panelsForCurrentDashboardMock[id];
|
||||
},
|
||||
};
|
||||
},
|
||||
getCurrent: () => dashboardModel,
|
||||
};
|
||||
},
|
||||
}));
|
||||
@ -68,6 +65,7 @@ function describeQueryRunnerScenario(description: string, scenarioFn: ScenarioFn
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
setEchoSrv(new Echo());
|
||||
setupFn();
|
||||
|
||||
const datasource: any = {
|
||||
@ -103,13 +101,6 @@ function describeQueryRunnerScenario(description: string, scenarioFn: ScenarioFn
|
||||
},
|
||||
});
|
||||
|
||||
panelsForCurrentDashboardMock[1] = {
|
||||
id: 1,
|
||||
getQueryRunner: () => {
|
||||
return ctx.runner;
|
||||
},
|
||||
} as PanelModel;
|
||||
|
||||
ctx.events = [];
|
||||
ctx.runner.run(args);
|
||||
});
|
||||
|
56
public/app/features/dashboard/state/analyticsProcessor.ts
Normal file
56
public/app/features/dashboard/state/analyticsProcessor.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import { getDashboardSrv } from '../services/DashboardSrv';
|
||||
|
||||
import { PanelData, LoadingState, DataSourceApi } from '@grafana/data';
|
||||
|
||||
import { reportMetaAnalytics, MetaAnalyticsEventPayload } from '@grafana/runtime';
|
||||
|
||||
export function getAnalyticsProcessor(datasource: DataSourceApi) {
|
||||
let done = false;
|
||||
|
||||
return (data: PanelData) => {
|
||||
if (!data.request || done) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.state !== LoadingState.Done && data.state !== LoadingState.Error) {
|
||||
return;
|
||||
}
|
||||
|
||||
const eventData: MetaAnalyticsEventPayload = {
|
||||
datasourceName: datasource.name,
|
||||
datasourceId: datasource.id,
|
||||
panelId: data.request.panelId,
|
||||
dashboardId: data.request.dashboardId,
|
||||
// app: 'dashboard',
|
||||
// count: 1,
|
||||
dataSize: 0,
|
||||
duration: data.request.endTime - data.request.startTime,
|
||||
eventName: 'data-request',
|
||||
// sessionId: '',
|
||||
};
|
||||
|
||||
// enrich with dashboard info
|
||||
const dashboard = getDashboardSrv().getCurrent();
|
||||
if (dashboard) {
|
||||
eventData.dashboardId = dashboard.id;
|
||||
eventData.dashboardName = dashboard.title;
|
||||
eventData.dashboardUid = dashboard.uid;
|
||||
eventData.folderName = dashboard.meta.folderTitle;
|
||||
}
|
||||
|
||||
if (data.series.length > 0) {
|
||||
// estimate size
|
||||
eventData.dataSize = data.series.length * data.series[0].length;
|
||||
}
|
||||
|
||||
if (data.error) {
|
||||
eventData.error = data.error.message;
|
||||
}
|
||||
|
||||
reportMetaAnalytics(eventData);
|
||||
|
||||
// this done check is to make sure we do not double emit events in case
|
||||
// there are multiple responses with done state
|
||||
done = true;
|
||||
};
|
||||
}
|
@ -10,9 +10,24 @@ import {
|
||||
import { Subscriber, Observable, Subscription } from 'rxjs';
|
||||
import { runRequest } from './runRequest';
|
||||
import { deepFreeze } from '../../../../test/core/redux/reducerTester';
|
||||
import { DashboardModel } from './DashboardModel';
|
||||
import { setEchoSrv } from '@grafana/runtime';
|
||||
import { Echo } from '../../../core/services/echo/Echo';
|
||||
|
||||
jest.mock('app/core/services/backend_srv');
|
||||
|
||||
const dashboardModel = new DashboardModel({
|
||||
panels: [{ id: 1, type: 'graph' }],
|
||||
});
|
||||
|
||||
jest.mock('app/features/dashboard/services/DashboardSrv', () => ({
|
||||
getDashboardSrv: () => {
|
||||
return {
|
||||
getCurrent: () => dashboardModel,
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
class ScenarioCtx {
|
||||
ds: DataSourceApi;
|
||||
request: DataQueryRequest;
|
||||
@ -84,6 +99,7 @@ function runRequestScenario(desc: string, fn: (ctx: ScenarioCtx) => void) {
|
||||
const ctx = new ScenarioCtx();
|
||||
|
||||
beforeEach(() => {
|
||||
setEchoSrv(new Echo());
|
||||
ctx.reset();
|
||||
return ctx.setupFn();
|
||||
});
|
||||
|
@ -1,7 +1,7 @@
|
||||
// Libraries
|
||||
import { Observable, of, timer, merge, from } from 'rxjs';
|
||||
import { flatten, map as lodashMap, isArray, isString } from 'lodash';
|
||||
import { map, catchError, takeUntil, mapTo, share, finalize } from 'rxjs/operators';
|
||||
import { map, catchError, takeUntil, mapTo, share, finalize, tap } from 'rxjs/operators';
|
||||
// Utils & Services
|
||||
import { getBackendSrv } from 'app/core/services/backend_srv';
|
||||
// Types
|
||||
@ -18,6 +18,7 @@ import {
|
||||
DataFrame,
|
||||
guessFieldTypes,
|
||||
} from '@grafana/data';
|
||||
import { getAnalyticsProcessor } from './analyticsProcessor';
|
||||
import { ExpressionDatasourceID, expressionDatasource } from 'app/features/expressions/ExpressionDatasource';
|
||||
|
||||
type MapOfResponsePackets = { [str: string]: DataQueryResponse };
|
||||
@ -119,6 +120,7 @@ export function runRequest(datasource: DataSourceApi, request: DataQueryRequest)
|
||||
error: processQueryError(err),
|
||||
})
|
||||
),
|
||||
tap(getAnalyticsProcessor(datasource)),
|
||||
// finalize is triggered when subscriber unsubscribes
|
||||
// This makes sure any still running network requests are cancelled
|
||||
finalize(cancelNetworkRequestsOnUnsubscribe(request)),
|
||||
|
@ -1,2 +1,4 @@
|
||||
import app from './app';
|
||||
|
||||
app.initEchoSrv();
|
||||
app.init();
|
||||
|
@ -141,7 +141,6 @@ export function grafanaAppDirective(
|
||||
controller: GrafanaCtrl,
|
||||
link: (scope: IRootScopeService & AppEventEmitter, elem: JQuery) => {
|
||||
const body = $('body');
|
||||
|
||||
// see https://github.com/zenorocha/clipboard.js/issues/155
|
||||
$.fn.modal.Constructor.prototype.enforceFocus = () => {};
|
||||
|
||||
|
File diff suppressed because one or more lines are too long
@ -81,7 +81,7 @@ module.exports = (env = {}) =>
|
||||
new HtmlWebpackPlugin({
|
||||
filename: path.resolve(__dirname, '../../public/views/index.html'),
|
||||
template: path.resolve(__dirname, '../../public/views/index-template.html'),
|
||||
inject: 'body',
|
||||
inject: false,
|
||||
chunksSortMode: 'none',
|
||||
excludeChunks: ['dark', 'light']
|
||||
}),
|
||||
|
@ -77,7 +77,7 @@ module.exports = merge(common, {
|
||||
new HtmlWebpackPlugin({
|
||||
filename: path.resolve(__dirname, '../../public/views/index.html'),
|
||||
template: path.resolve(__dirname, '../../public/views/index-template.html'),
|
||||
inject: 'body',
|
||||
inject: false,
|
||||
excludeChunks: ['manifest', 'dark', 'light'],
|
||||
chunksSortMode: 'none'
|
||||
}),
|
||||
|
@ -20371,6 +20371,11 @@ tsutils@^3.9.1:
|
||||
dependencies:
|
||||
tslib "^1.8.1"
|
||||
|
||||
tti-polyfill@0.2.2:
|
||||
version "0.2.2"
|
||||
resolved "https://registry.yarnpkg.com/tti-polyfill/-/tti-polyfill-0.2.2.tgz#f7bbf71b13afa9edf60c8bb0d0c05f134e1513b9"
|
||||
integrity sha512-URIoJxvsHThbQEJij29hIBUDHx9UNoBBCQVjy7L8PnzkqY8N6lsAI6h8JrT1Wt2lA0avus/DkuiJxd9qpfCpqw==
|
||||
|
||||
tty-browserify@0.0.0:
|
||||
version "0.0.0"
|
||||
resolved "https://registry.yarnpkg.com/tty-browserify/-/tty-browserify-0.0.0.tgz#a157ba402da24e9bf957f9aa69d524eed42901a6"
|
||||
|
Loading…
Reference in New Issue
Block a user