Improve Superset logger (#6879)

* enable beacon

* add middleware

* add unit tests
This commit is contained in:
Grace Guo 2019-02-15 23:10:05 -08:00 committed by GitHub
parent bd9a2c15e7
commit 47f42ed351
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 569 additions and 552 deletions

View File

@ -19,7 +19,7 @@
import fetchMock from 'fetch-mock';
import sinon from 'sinon';
import { Logger } from '../../../src/logger';
import { LOG_EVENT } from '../../../src/logger/actions';
import * as exploreUtils from '../../../src/explore/exploreUtils';
import * as actions from '../../../src/chart/chartAction';
@ -27,7 +27,6 @@ describe('chart actions', () => {
const MOCK_URL = '/mockURL';
let dispatch;
let urlStub;
let loggerStub;
const setupDefaultFetchMock = () => {
fetchMock.post(MOCK_URL, { json: {} }, { overwriteRoutes: true });
@ -44,12 +43,10 @@ describe('chart actions', () => {
urlStub = sinon
.stub(exploreUtils, 'getExploreUrlAndPayload')
.callsFake(() => ({ url: MOCK_URL, payload: {} }));
loggerStub = sinon.stub(Logger, 'append');
});
afterEach(() => {
urlStub.restore();
loggerStub.restore();
fetchMock.resetHistory();
});
@ -58,7 +55,7 @@ describe('chart actions', () => {
return actionThunk(dispatch).then(() => {
// chart update, trigger query, update form data, success
expect(dispatch.callCount).toBe(4);
expect(dispatch.callCount).toBe(5);
expect(fetchMock.calls(MOCK_URL)).toHaveLength(1);
expect(dispatch.args[0][0].type).toBe(actions.CHART_UPDATE_STARTED);
@ -70,7 +67,7 @@ describe('chart actions', () => {
const actionThunk = actions.runQuery({});
return actionThunk(dispatch).then(() => {
// chart update, trigger query, update form data, success
expect(dispatch.callCount).toBe(4);
expect(dispatch.callCount).toBe(5);
expect(fetchMock.calls(MOCK_URL)).toHaveLength(1);
expect(dispatch.args[1][0].type).toBe(actions.TRIGGER_QUERY);
@ -82,7 +79,7 @@ describe('chart actions', () => {
const actionThunk = actions.runQuery({});
return actionThunk(dispatch).then(() => {
// chart update, trigger query, update form data, success
expect(dispatch.callCount).toBe(4);
expect(dispatch.callCount).toBe(5);
expect(fetchMock.calls(MOCK_URL)).toHaveLength(1);
expect(dispatch.args[2][0].type).toBe(actions.UPDATE_QUERY_FORM_DATA);
@ -90,14 +87,29 @@ describe('chart actions', () => {
});
});
it('should dispatch logEvent async action', () => {
const actionThunk = actions.runQuery({});
return actionThunk(dispatch).then(() => {
// chart update, trigger query, update form data, success
expect(dispatch.callCount).toBe(5);
expect(fetchMock.calls(MOCK_URL)).toHaveLength(1);
expect(typeof dispatch.args[3][0]).toBe('function');
dispatch.args[3][0](dispatch);
expect(dispatch.callCount).toBe(6);
expect(dispatch.args[5][0].type).toBe(LOG_EVENT);
return Promise.resolve();
});
});
it('should dispatch CHART_UPDATE_SUCCEEDED action upon success', () => {
const actionThunk = actions.runQuery({});
return actionThunk(dispatch).then(() => {
// chart update, trigger query, update form data, success
expect(dispatch.callCount).toBe(4);
expect(dispatch.callCount).toBe(5);
expect(fetchMock.calls(MOCK_URL)).toHaveLength(1);
expect(dispatch.args[3][0].type).toBe(actions.CHART_UPDATE_SUCCEEDED);
expect(loggerStub.callCount).toBe(1);
expect(dispatch.args[4][0].type).toBe(actions.CHART_UPDATE_SUCCEEDED);
return Promise.resolve();
});
@ -112,10 +124,8 @@ describe('chart actions', () => {
return actionThunk(dispatch).then(() => {
// chart update, trigger query, update form data, fail
expect(dispatch.callCount).toBe(4);
expect(dispatch.args[3][0].type).toBe(actions.CHART_UPDATE_TIMEOUT);
expect(loggerStub.callCount).toBe(1);
expect(loggerStub.args[0][1].error_details).toBe('timeout');
expect(dispatch.callCount).toBe(5);
expect(dispatch.args[4][0].type).toBe(actions.CHART_UPDATE_TIMEOUT);
setupDefaultFetchMock();
return Promise.resolve();
@ -130,13 +140,11 @@ describe('chart actions', () => {
return actionThunk(dispatch).then(() => {
// chart update, trigger query, update form data, fail
expect(dispatch.callCount).toBe(4);
const updateFailedAction = dispatch.args[3][0];
expect(dispatch.callCount).toBe(5);
const updateFailedAction = dispatch.args[4][0];
expect(updateFailedAction.type).toBe(actions.CHART_UPDATE_FAILED);
expect(updateFailedAction.queryResponse.error).toBe('misc error');
expect(loggerStub.callCount).toBe(1);
expect(loggerStub.args[0][1].error_details).toBe('misc error');
setupDefaultFetchMock();
return Promise.resolve();

View File

@ -40,6 +40,7 @@ describe('Dashboard', () => {
addSliceToDashboard() {},
removeSliceFromDashboard() {},
runQuery() {},
logEvent() {},
},
initMessages: [],
dashboardState,

View File

@ -54,6 +54,7 @@ describe('Chart', () => {
refreshChart() {},
toggleExpandSlice() {},
addFilter() {},
logEvent() {},
editMode: false,
isExpanded: false,
supersetCanExplore: false,

View File

@ -51,6 +51,7 @@ describe('Markdown', () => {
handleComponentDrop() {},
updateComponents() {},
deleteComponent() {},
logEvent() {},
};
function setup(overrideProps) {

View File

@ -52,6 +52,7 @@ describe('Tabs', () => {
onChangeTab() {},
deleteComponent() {},
updateComponents() {},
logEvent() {},
};
function setup(overrideProps) {

View File

@ -1,179 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import fetchMock from 'fetch-mock';
import { Logger, ActionLog } from '../../src/logger';
describe('ActionLog', () => {
it('should be a constructor', () => {
const newLogger = new ActionLog({});
expect(newLogger instanceof ActionLog).toBe(true);
});
it(
'should set the eventNames, impressionId, source, sourceId, and sendNow init parameters',
() => {
const eventNames = [];
const impressionId = 'impressionId';
const source = 'source';
const sourceId = 'sourceId';
const sendNow = true;
const log = new ActionLog({ eventNames, impressionId, source, sourceId, sendNow });
expect(log.eventNames).toBe(eventNames);
expect(log.impressionId).toBe(impressionId);
expect(log.source).toBe(source);
expect(log.sourceId).toBe(sourceId);
expect(log.sendNow).toBe(sendNow);
},
);
it('should set attributes with the setAttribute method', () => {
const log = new ActionLog({});
expect(log.test).toBeUndefined();
log.setAttribute('test', 'testValue');
expect(log.test).toBe('testValue');
});
it('should track added events', () => {
const log = new ActionLog({});
const eventName = 'myEventName';
const eventBody = { test: 'event' };
expect(log.events[eventName]).toBeUndefined();
log.addEvent(eventName, eventBody);
expect(log.events[eventName]).toHaveLength(1);
expect(log.events[eventName][0]).toMatchObject(eventBody);
Logger.end(log);
});
});
describe('Logger', () => {
const logEndpoint = 'glob:*/superset/log/*';
fetchMock.post(logEndpoint, 'success');
afterEach(fetchMock.resetHistory);
it('should add events when .append(eventName, eventBody) is called', () => {
const eventName = 'testEvent';
const eventBody = { test: 'event' };
const log = new ActionLog({ eventNames: [eventName] });
Logger.start(log);
Logger.append(eventName, eventBody);
expect(log.events[eventName]).toHaveLength(1);
expect(log.events[eventName][0]).toMatchObject(eventBody);
Logger.end(log);
});
describe('.send()', () => {
const eventNames = ['test'];
function setup(overrides = {}) {
const log = new ActionLog({ eventNames, ...overrides });
return log;
}
it('should POST an event to /superset/log/ when called', (done) => {
const log = setup();
Logger.start(log);
Logger.append(eventNames[0], { test: 'event' });
expect(log.events[eventNames[0]]).toHaveLength(1);
Logger.end(log);
setTimeout(() => {
expect(fetchMock.calls(logEndpoint)).toHaveLength(1);
done();
}, 0);
});
it("should flush the log's events", () => {
const log = setup();
Logger.start(log);
Logger.append(eventNames[0], { test: 'event' });
const event = log.events[eventNames[0]][0];
expect(event).toMatchObject({ test: 'event' });
Logger.end(log);
expect(log.events).toEqual({});
});
it(
'should include ts, start_offset, event_name, impression_id, source, and source_id in every event',
(done) => {
const config = {
eventNames: ['event1', 'event2'],
impressionId: 'impress_me',
source: 'superset',
sourceId: 'lolz',
};
const log = setup(config);
Logger.start(log);
Logger.append('event1', { key: 'value' });
Logger.append('event2', { foo: 'bar' });
Logger.end(log);
setTimeout(() => {
const calls = fetchMock.calls(logEndpoint);
expect(calls).toHaveLength(1);
const options = calls[0][1];
const events = JSON.parse(options.body.get('events'));
expect(events).toHaveLength(2);
expect(events[0]).toMatchObject({
key: 'value',
event_name: 'event1',
impression_id: config.impressionId,
source: config.source,
source_id: config.sourceId,
});
expect(events[1]).toMatchObject({
foo: 'bar',
event_name: 'event2',
impression_id: config.impressionId,
source: config.source,
source_id: config.sourceId,
});
expect(typeof events[0].ts).toBe('number');
expect(typeof events[1].ts).toBe('number');
expect(typeof events[0].start_offset).toBe('number');
expect(typeof events[1].start_offset).toBe('number');
done();
}, 0);
},
);
it(
'should send() a log immediately if .append() is called with sendNow=true',
(done) => {
const log = setup();
Logger.start(log);
Logger.append(eventNames[0], { test: 'event' }, true);
setTimeout(() => {
expect(fetchMock.calls(logEndpoint)).toHaveLength(1);
Logger.end(log); // flush logs
done();
}, 0);
},
);
});
});

View File

@ -0,0 +1,111 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import sinon from 'sinon';
import { SupersetClient } from '@superset-ui/connection';
import logger from '../../../src/middleware/loggerMiddleware';
import { LOG_EVENT } from '../../../src/logger/actions';
import { LOG_ACTIONS_LOAD_CHART } from '../../../src/logger/LogUtils';
describe('logger middleware', () => {
const next = sinon.spy();
const mockStore = {
getState: () => ({
dashboardInfo: {
id: 1,
},
impressionId: 'impression_id',
}),
};
const action = {
type: LOG_EVENT,
payload: {
eventName: LOG_ACTIONS_LOAD_CHART,
eventData: {
key: 'value',
start_offset: 100,
},
},
};
let postStub;
beforeEach(() => {
postStub = sinon.stub(SupersetClient, 'post');
});
afterEach(() => {
next.reset();
postStub.restore();
});
it('should listen to LOG_EVENT action type', () => {
const action1 = {
type: 'ACTION_TYPE',
payload: {
some: 'data',
},
};
logger(mockStore)(next)(action1);
expect(next.callCount).toBe(1);
});
it('should POST an event to /superset/log/ when called', () => {
const clock = sinon.useFakeTimers();
logger(mockStore)(next)(action);
expect(next.callCount).toBe(0);
clock.tick(2000);
expect(SupersetClient.post.callCount).toBe(1);
expect(SupersetClient.post.getCall(0).args[0].endpoint).toMatch('/superset/log/');
});
it(
'should include ts, start_offset, event_name, impression_id, source, and source_id in every event',
() => {
const clock = sinon.useFakeTimers();
logger(mockStore)(next)(action);
clock.tick(2000);
expect(SupersetClient.post.callCount).toBe(1);
const events = SupersetClient.post.getCall(0).args[0].postPayload.events;
const mockEventdata = action.payload.eventData;
const mockEventname = action.payload.eventName;
expect(events[0]).toMatchObject({
key: mockEventdata.key,
event_name: mockEventname,
impression_id: mockStore.getState().impressionId,
source: 'dashboard',
source_id: mockStore.getState().dashboardInfo.id,
event_type: 'timing',
});
expect(typeof events[0].ts).toBe('number');
expect(typeof events[0].start_offset).toBe('number');
},
);
it('should debounce a few log requests to one', () => {
const clock = sinon.useFakeTimers();
logger(mockStore)(next)(action);
logger(mockStore)(next)(action);
logger(mockStore)(next)(action);
clock.tick(2000);
expect(SupersetClient.post.callCount).toBe(1);
expect(SupersetClient.post.getCall(0).args[0].postPayload.events).toHaveLength(3);
});
});

View File

@ -18,7 +18,7 @@
*/
import PropTypes from 'prop-types';
import React from 'react';
import { Logger, LOG_ACTIONS_RENDER_CHART_CONTAINER } from '../logger';
import { Logger, LOG_ACTIONS_RENDER_CHART_CONTAINER } from '../logger/LogUtils';
import Loading from '../components/Loading';
import RefreshChartOverlay from '../components/RefreshChartOverlay';
import StackTraceMessage from '../components/StackTraceMessage';
@ -73,16 +73,17 @@ class Chart extends React.PureComponent {
}
}
handleRenderFailure(error, info) {
handleRenderContainerFailure(error, info) {
const { actions, chartId } = this.props;
console.warn(error); // eslint-disable-line
actions.chartRenderingFailed(error.toString(), chartId, info ? info.componentStack : null);
Logger.append(LOG_ACTIONS_RENDER_CHART_CONTAINER, {
actions.logEvent(LOG_ACTIONS_RENDER_CHART_CONTAINER, {
slice_id: chartId,
has_err: true,
error_details: error.toString(),
start_offset: this.renderStartTime,
ts: new Date().getTime(),
duration: Logger.getTimestamp() - this.renderStartTime,
});
}

View File

@ -20,11 +20,15 @@ import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import * as actions from './chartAction';
import { logEvent } from '../logger/actions';
import Chart from './Chart';
function mapDispatchToProps(dispatch) {
return {
actions: bindActionCreators(actions, dispatch),
actions: bindActionCreators({
...actions,
logEvent,
}, dispatch),
};
}

View File

@ -22,7 +22,7 @@ import PropTypes from 'prop-types';
import React from 'react';
import { ChartProps, SuperChart } from '@superset-ui/chart';
import { Tooltip } from 'react-bootstrap';
import { Logger, LOG_ACTIONS_RENDER_CHART } from '../logger';
import { Logger, LOG_ACTIONS_RENDER_CHART } from '../logger/LogUtils';
const propTypes = {
annotationData: PropTypes.object,
@ -61,6 +61,7 @@ class ChartRenderer extends React.Component {
this.state = {};
this.createChartProps = ChartProps.createSelector();
this.hasQueryResponseChnage = false;
this.setTooltip = this.setTooltip.bind(this);
this.handleAddFilter = this.handleAddFilter.bind(this);
@ -69,18 +70,23 @@ class ChartRenderer extends React.Component {
}
shouldComponentUpdate(nextProps) {
if (
const resultsReady =
nextProps.queryResponse &&
['success', 'rendered'].indexOf(nextProps.chartStatus) > -1 &&
!nextProps.queryResponse.error &&
!nextProps.refreshOverlayVisible &&
(nextProps.annotationData !== this.props.annotationData ||
nextProps.queryResponse !== this.props.queryResponse ||
!nextProps.refreshOverlayVisible;
if (resultsReady) {
this.hasQueryResponseChnage =
nextProps.queryResponse !== this.props.queryResponse;
if (this.hasQueryResponseChnage ||
nextProps.annotationData !== this.props.annotationData ||
nextProps.height !== this.props.height ||
nextProps.width !== this.props.width ||
nextProps.triggerRender)
) {
return true;
nextProps.triggerRender) {
return true;
}
}
return false;
}
@ -126,12 +132,17 @@ class ChartRenderer extends React.Component {
actions.chartRenderingSucceeded(chartId);
}
Logger.append(LOG_ACTIONS_RENDER_CHART, {
slice_id: chartId,
viz_type: vizType,
start_offset: this.renderStartTime,
duration: Logger.getTimestamp() - this.renderStartTime,
});
// only log chart render time which is triggered by query results change
// currently we don't log chart re-render time, like window resize etc
if (this.hasQueryResponseChnage) {
actions.logEvent(LOG_ACTIONS_RENDER_CHART, {
slice_id: chartId,
viz_type: vizType,
start_offset: this.renderStartTime,
ts: new Date().getTime(),
duration: Logger.getTimestamp() - this.renderStartTime,
});
}
}
handleRenderFailure(error, info) {
@ -139,13 +150,17 @@ class ChartRenderer extends React.Component {
console.warn(error); // eslint-disable-line
actions.chartRenderingFailed(error.toString(), chartId, info ? info.componentStack : null);
Logger.append(LOG_ACTIONS_RENDER_CHART, {
slice_id: chartId,
has_err: true,
error_details: error.toString(),
start_offset: this.renderStartTime,
duration: Logger.getTimestamp() - this.renderStartTime,
});
// only trigger render log when query is changed
if (this.hasQueryResponseChnage) {
actions.logEvent(LOG_ACTIONS_RENDER_CHART, {
slice_id: chartId,
has_err: true,
error_details: error.toString(),
start_offset: this.renderStartTime,
ts: new Date().getTime(),
duration: Logger.getTimestamp() - this.renderStartTime,
});
}
}
renderTooltip() {

View File

@ -24,7 +24,8 @@ import { SupersetClient } from '@superset-ui/connection';
import { getExploreUrlAndPayload, getAnnotationJsonUrl } from '../explore/exploreUtils';
import { requiresQuery, ANNOTATION_SOURCE_TYPES } from '../modules/AnnotationTypes';
import { addDangerToast } from '../messageToasts/actions';
import { Logger, LOG_ACTIONS_LOAD_CHART } from '../logger';
import { logEvent } from '../logger/actions';
import { Logger, LOG_ACTIONS_LOAD_CHART } from '../logger/LogUtils';
import getClientErrorObject from '../utils/getClientErrorObject';
import { allowCrossDomain } from '../utils/hostNamesConfig';
@ -194,29 +195,31 @@ export function runQuery(formData, force = false, timeout = 60, key) {
}
const queryPromise = SupersetClient.post(querySettings)
.then(({ json }) => {
Logger.append(LOG_ACTIONS_LOAD_CHART, {
dispatch(logEvent(LOG_ACTIONS_LOAD_CHART, {
slice_id: key,
is_cached: json.is_cached,
force_refresh: force,
row_count: json.rowcount,
datasource: formData.datasource,
start_offset: logStart,
ts: new Date().getTime(),
duration: Logger.getTimestamp() - logStart,
has_extra_filters: formData.extra_filters && formData.extra_filters.length > 0,
viz_type: formData.viz_type,
});
}));
return dispatch(chartUpdateSucceeded(json, key));
})
.catch((response) => {
const appendErrorLog = (errorDetails) => {
Logger.append(LOG_ACTIONS_LOAD_CHART, {
dispatch(logEvent(LOG_ACTIONS_LOAD_CHART, {
slice_id: key,
has_err: true,
error_details: errorDetails,
datasource: formData.datasource,
start_offset: logStart,
ts: new Date().getTime(),
duration: Logger.getTimestamp() - logStart,
});
}));
};
if (response.statusText === 'timeout') {

View File

@ -27,7 +27,7 @@ import {
Logger,
ActionLog,
LOG_ACTIONS_OMNIBAR_TRIGGERED,
} from '../logger';
} from '../logger/LogUtils';
const propTypes = {
impressionId: PropTypes.string.isRequired,

View File

@ -24,6 +24,7 @@ import { hot } from 'react-hot-loader';
import { initFeatureFlags } from 'src/featureFlags';
import { initEnhancer } from '../reduxUtils';
import logger from '../middleware/loggerMiddleware';
import setupApp from '../setup/setupApp';
import setupPlugins from '../setup/setupPlugins';
import DashboardContainer from './containers/Dashboard';
@ -42,7 +43,7 @@ const store = createStore(
rootReducer,
initState,
compose(
applyMiddleware(thunk),
applyMiddleware(thunk, logger),
initEnhancer(false),
),
);

View File

@ -26,11 +26,6 @@ import { chart as initChart } from '../../chart/chartReducer';
import { fetchDatasourceMetadata } from '../../dashboard/actions/datasources';
import { applyDefaultFormData } from '../../explore/store';
import getClientErrorObject from '../../utils/getClientErrorObject';
import {
Logger,
LOG_ACTIONS_CHANGE_DASHBOARD_FILTER,
LOG_ACTIONS_REFRESH_DASHBOARD,
} from '../../logger';
import { SAVE_TYPE_OVERWRITE } from '../util/constants';
import {
addSuccessToast,
@ -45,13 +40,6 @@ export function setUnsavedChanges(hasUnsavedChanges) {
export const CHANGE_FILTER = 'CHANGE_FILTER';
export function changeFilter(chart, col, vals, merge = true, refresh = true) {
Logger.append(LOG_ACTIONS_CHANGE_DASHBOARD_FILTER, {
id: chart.id,
column: col,
value_count: Array.isArray(vals) ? vals.length : (vals && 1) || 0,
merge,
refresh,
});
return { type: CHANGE_FILTER, chart, col, vals, merge, refresh };
}
@ -174,11 +162,6 @@ export function saveDashboardRequest(data, id, saveType) {
export function fetchCharts(chartList = [], force = false, interval = 0) {
return (dispatch, getState) => {
Logger.append(LOG_ACTIONS_REFRESH_DASHBOARD, {
force,
interval,
chartCount: chartList.length,
});
const timeout = getState().dashboardInfo.common.conf
.SUPERSET_WEBSERVER_TIMEOUT;
if (!interval) {

View File

@ -31,15 +31,8 @@ import {
} from '../util/propShapes';
import { areObjectsEqual } from '../../reduxUtils';
import getFormDataWithExtraFilters from '../util/charts/getFormDataWithExtraFilters';
import {
Logger,
ActionLog,
DASHBOARD_EVENT_NAMES,
LOG_ACTIONS_MOUNT_DASHBOARD,
LOG_ACTIONS_LOAD_DASHBOARD_PANE,
LOG_ACTIONS_FIRST_DASHBOARD_LOAD,
} from '../../logger';
import OmniContianer from '../../components/OmniContainer';
import { LOG_ACTIONS_MOUNT_DASHBOARD } from '../../logger/LogUtils';
import OmniContainer from '../../components/OmniContainer';
import '../stylesheets/index.less';
@ -48,6 +41,7 @@ const propTypes = {
addSliceToDashboard: PropTypes.func.isRequired,
removeSliceFromDashboard: PropTypes.func.isRequired,
runQuery: PropTypes.func.isRequired,
logEvent: PropTypes.func.isRequired,
}).isRequired,
dashboardInfo: dashboardInfoPropShape.isRequired,
dashboardState: dashboardStatePropShape.isRequired,
@ -84,71 +78,11 @@ class Dashboard extends React.PureComponent {
return message; // Gecko + Webkit, Safari, Chrome etc.
}
constructor(props) {
super(props);
this.isFirstLoad = true;
this.actionLog = new ActionLog({
impressionId: props.impressionId,
source: 'dashboard',
sourceId: props.dashboardInfo.id,
eventNames: DASHBOARD_EVENT_NAMES,
});
Logger.start(this.actionLog);
this.initTs = new Date().getTime();
}
componentDidMount() {
Logger.append(LOG_ACTIONS_MOUNT_DASHBOARD);
this.props.actions.logEvent(LOG_ACTIONS_MOUNT_DASHBOARD);
}
componentWillReceiveProps(nextProps) {
if (!nextProps.dashboardState.editMode) {
// log pane loads
const loadedPaneIds = [];
let minQueryStartTime = Infinity;
const allVisiblePanesDidLoad = Object.entries(nextProps.loadStats).every(
([paneId, stats]) => {
const {
didLoad,
minQueryStartTime: paneMinQueryStart,
...restStats
} = stats;
if (
didLoad &&
this.props.loadStats[paneId] &&
!this.props.loadStats[paneId].didLoad
) {
Logger.append(LOG_ACTIONS_LOAD_DASHBOARD_PANE, {
...restStats,
duration: new Date().getTime() - paneMinQueryStart,
version: 'v2',
});
if (!this.isFirstLoad) {
Logger.send(this.actionLog);
}
}
if (this.isFirstLoad && didLoad && stats.slice_ids.length > 0) {
loadedPaneIds.push(paneId);
minQueryStartTime = Math.min(minQueryStartTime, paneMinQueryStart);
}
// return true if it is loaded, or it's index is not 0
return didLoad || stats.index !== 0;
},
);
if (allVisiblePanesDidLoad && this.isFirstLoad) {
Logger.append(LOG_ACTIONS_FIRST_DASHBOARD_LOAD, {
pane_ids: loadedPaneIds,
duration: new Date().getTime() - minQueryStartTime,
version: 'v2',
});
Logger.send(this.actionLog);
this.isFirstLoad = false;
}
}
const currentChartIds = getChartIdsFromLayout(this.props.layout);
const nextChartIds = getChartIdsFromLayout(nextProps.layout);
@ -240,7 +174,7 @@ class Dashboard extends React.PureComponent {
return (
<React.Fragment>
<OmniContianer impressionId={impressionId} dashboardId={id} />
<OmniContainer impressionId={impressionId} dashboardId={id} />
<DashboardBuilder />
</React.Fragment>
);

View File

@ -35,6 +35,12 @@ import {
} from '../util/constants';
import { safeStringify } from '../../utils/safeStringify';
import {
LOG_ACTIONS_PERIODIC_RENDER_DASHBOARD,
LOG_ACTIONS_FORCE_REFRESH_DASHBOARD,
LOG_ACTIONS_TOGGLE_EDIT_DASHBOARD,
} from '../../logger/LogUtils';
const propTypes = {
addSuccessToast: PropTypes.func.isRequired,
addDangerToast: PropTypes.func.isRequired,
@ -60,6 +66,7 @@ const propTypes = {
showBuilderPane: PropTypes.bool.isRequired,
toggleBuilderPane: PropTypes.func.isRequired,
updateCss: PropTypes.func.isRequired,
logEvent: PropTypes.func.isRequired,
hasUnsavedChanges: PropTypes.bool.isRequired,
maxUndoHistoryExceeded: PropTypes.bool.isRequired,
@ -89,6 +96,7 @@ class Header extends React.PureComponent {
this.handleCtrlY = this.handleCtrlY.bind(this);
this.toggleEditMode = this.toggleEditMode.bind(this);
this.forceRefresh = this.forceRefresh.bind(this);
this.startPeriodicRender = this.startPeriodicRender.bind(this);
this.overwriteDashboard = this.overwriteDashboard.bind(this);
}
@ -115,11 +123,25 @@ class Header extends React.PureComponent {
forceRefresh() {
if (!this.props.isLoading) {
return this.props.fetchCharts(Object.values(this.props.charts), true);
const chartList = Object.values(this.props.charts);
this.props.logEvent(LOG_ACTIONS_FORCE_REFRESH_DASHBOARD, {
force: true,
interval: 0,
chartCount: chartList.length,
});
return this.props.fetchCharts(chartList, true);
}
return false;
}
startPeriodicRender(interval) {
this.props.logEvent(LOG_ACTIONS_PERIODIC_RENDER_DASHBOARD, {
force: true,
interval,
});
return this.props.startPeriodicRender(interval);
}
handleChangeText(nextText) {
const { updateDashboardTitle, onChange } = this.props;
if (nextText && this.props.dashboardTitle !== nextText) {
@ -150,6 +172,9 @@ class Header extends React.PureComponent {
toggleEditMode() {
this.props.setEditMode(!this.props.editMode);
this.props.logEvent(LOG_ACTIONS_TOGGLE_EDIT_DASHBOARD, {
editMode: !this.props.editMode,
});
}
overwriteDashboard() {
@ -320,7 +345,7 @@ class Header extends React.PureComponent {
onSave={onSave}
onChange={onChange}
forceRefreshAllCharts={this.forceRefresh}
startPeriodicRender={this.props.startPeriodicRender}
startPeriodicRender={this.startPeriodicRender}
updateCss={updateCss}
editMode={editMode}
hasUnsavedChanges={hasUnsavedChanges}

View File

@ -22,13 +22,6 @@ import moment from 'moment';
import { Dropdown, MenuItem } from 'react-bootstrap';
import { t } from '@superset-ui/translation';
import {
Logger,
LOG_ACTIONS_EXPLORE_DASHBOARD_CHART,
LOG_ACTIONS_EXPORT_CSV_DASHBOARD_CHART,
LOG_ACTIONS_REFRESH_CHART,
} from '../../logger';
const propTypes = {
slice: PropTypes.object.isRequired,
isCached: PropTypes.bool,
@ -83,35 +76,15 @@ class SliceHeaderControls extends React.PureComponent {
exportCSV() {
this.props.exportCSV(this.props.slice.slice_id);
Logger.append(
LOG_ACTIONS_EXPORT_CSV_DASHBOARD_CHART,
{
slice_id: this.props.slice.slice_id,
is_cached: this.props.isCached,
},
true,
);
}
exploreChart() {
this.props.exploreChart(this.props.slice.slice_id);
Logger.append(
LOG_ACTIONS_EXPLORE_DASHBOARD_CHART,
{
slice_id: this.props.slice.slice_id,
is_cached: this.props.isCached,
},
true,
);
}
refreshChart() {
if (this.props.updatedDttm) {
this.props.forceRefresh(this.props.slice.slice_id);
Logger.append(LOG_ACTIONS_REFRESH_CHART, {
slice_id: this.props.slice.slice_id,
is_cached: this.props.isCached,
});
}
}

View File

@ -24,6 +24,12 @@ import SliceHeader from '../SliceHeader';
import ChartContainer from '../../../chart/ChartContainer';
import MissingChart from '../MissingChart';
import { slicePropShape, chartPropShape } from '../../util/propShapes';
import {
LOG_ACTIONS_CHANGE_DASHBOARD_FILTER,
LOG_ACTIONS_EXPLORE_DASHBOARD_CHART,
LOG_ACTIONS_EXPORT_CSV_DASHBOARD_CHART,
LOG_ACTIONS_FORCE_REFRESH_CHART,
} from '../../../logger/LogUtils';
const propTypes = {
id: PropTypes.number.isRequired,
@ -40,14 +46,20 @@ const propTypes = {
timeout: PropTypes.number.isRequired,
filters: PropTypes.object.isRequired,
refreshChart: PropTypes.func.isRequired,
logEvent: PropTypes.func.isRequired,
toggleExpandSlice: PropTypes.func.isRequired,
addFilter: PropTypes.func.isRequired,
editMode: PropTypes.bool.isRequired,
isExpanded: PropTypes.bool.isRequired,
isCached: PropTypes.bool,
supersetCanExplore: PropTypes.bool.isRequired,
sliceCanEdit: PropTypes.bool.isRequired,
};
const defaultProps = {
isCached: false,
};
// we use state + shouldComponentUpdate() logic to prevent perf-wrecking
// resizing across all slices on a dashboard on every update
const RESIZE_TIMEOUT = 350;
@ -133,19 +145,38 @@ class Chart extends React.Component {
this.setState(() => ({ width, height }));
}
addFilter(...args) {
this.props.addFilter(this.props.chart, ...args);
addFilter(...[col, vals, merge, refresh]) {
this.props.logEvent(LOG_ACTIONS_CHANGE_DASHBOARD_FILTER, {
id: this.props.chart.id,
column: col,
value_count: Array.isArray(vals) ? vals.length : (vals && 1) || 0,
merge,
refresh,
});
this.props.addFilter(this.props.chart, col, vals, merge, refresh);
}
exploreChart() {
this.props.logEvent(LOG_ACTIONS_EXPLORE_DASHBOARD_CHART, {
slice_id: this.props.slice.slice_id,
is_cached: this.props.isCached,
});
exportChart(this.props.formData);
}
exportCSV() {
this.props.logEvent(LOG_ACTIONS_EXPORT_CSV_DASHBOARD_CHART, {
slice_id: this.props.slice.slice_id,
is_cached: this.props.isCached,
});
exportChart(this.props.formData, 'csv');
}
forceRefresh() {
this.props.logEvent(LOG_ACTIONS_FORCE_REFRESH_CHART, {
slice_id: this.props.slice.slice_id,
is_cached: this.props.isCached,
});
return this.props.refreshChart(this.props.chart, true, this.props.timeout);
}
@ -246,5 +277,6 @@ class Chart extends React.Component {
}
Chart.propTypes = propTypes;
Chart.defaultProps = defaultProps;
export default Chart;

View File

@ -36,6 +36,7 @@ import {
GRID_MIN_ROW_UNITS,
GRID_BASE_UNIT,
} from '../../util/constants';
import { Logger, LOG_ACTIONS_RENDER_CHART } from '../../../logger/LogUtils';
const propTypes = {
id: PropTypes.string.isRequired,
@ -46,6 +47,9 @@ const propTypes = {
depth: PropTypes.number.isRequired,
editMode: PropTypes.bool.isRequired,
// from redux
logEvent: PropTypes.func.isRequired,
// grid related
availableColumnCount: PropTypes.number.isRequired,
columnWidth: PropTypes.number.isRequired,
@ -78,6 +82,7 @@ class Markdown extends React.PureComponent {
editor: null,
editorMode: 'preview',
};
this.renderStartTime = Logger.getTimestamp();
this.handleChangeFocus = this.handleChangeFocus.bind(this);
this.handleChangeEditorMode = this.handleChangeEditorMode.bind(this);
@ -86,6 +91,15 @@ class Markdown extends React.PureComponent {
this.setEditor = this.setEditor.bind(this);
}
componentDidMount() {
this.props.logEvent(LOG_ACTIONS_RENDER_CHART, {
viz_type: 'markdown',
start_offset: this.renderStartTime,
ts: new Date().getTime(),
duration: Logger.getTimestamp() - this.renderStartTime,
});
}
componentWillReceiveProps(nextProps) {
const nextSource = nextProps.component.meta.code;
if (this.state.markdownSource !== nextSource) {

View File

@ -29,6 +29,7 @@ import { componentShape } from '../../util/propShapes';
import { NEW_TAB_ID, DASHBOARD_ROOT_ID } from '../../util/constants';
import { RENDER_TAB, RENDER_TAB_CONTENT } from './Tab';
import { TAB_TYPE } from '../../util/componentTypes';
import { LOG_ACTIONS_SELECT_DASHBOARD_TAB } from '../../../logger/LogUtils';
const NEW_TAB_INDEX = -1;
const MAX_TAB_COUNT = 7;
@ -43,6 +44,7 @@ const propTypes = {
renderTabContent: PropTypes.bool, // whether to render tabs + content or just tabs
editMode: PropTypes.bool.isRequired,
renderHoverMenu: PropTypes.bool,
logEvent: PropTypes.func.isRequired,
// grid related
availableColumnCount: PropTypes.number,
@ -106,6 +108,11 @@ class Tabs extends React.PureComponent {
},
});
} else if (tabIndex !== this.state.tabIndex) {
this.props.logEvent(LOG_ACTIONS_SELECT_DASHBOARD_TAB, {
id: component.id,
index: tabIndex,
});
this.setState(() => ({ tabIndex }));
this.props.onChangeTab({ tabIndex, tabId: component.children[tabIndex] });
}

View File

@ -24,6 +24,7 @@ import {
toggleExpandSlice,
} from '../actions/dashboardState';
import { refreshChart } from '../../chart/chartAction';
import { logEvent } from '../../logger/actions';
import getFormDataWithExtraFilters from '../util/charts/getFormDataWithExtraFilters';
import { updateComponents } from '../actions/dashboardLayout';
import Chart from '../components/gridComponents/Chart';
@ -73,6 +74,7 @@ function mapDispatchToProps(dispatch) {
toggleExpandSlice,
addFilter,
refreshChart,
logEvent,
},
dispatch,
);

View File

@ -26,6 +26,7 @@ import {
removeSliceFromDashboard,
} from '../actions/dashboardState';
import { runQuery } from '../../chart/chartAction';
import { logEvent } from '../../logger/actions';
import getLoadStatsPerTopLevelComponent from '../util/logging/getLoadStatsPerTopLevelComponent';
function mapStateToProps(state) {
@ -64,6 +65,7 @@ function mapDispatchToProps(dispatch) {
addSliceToDashboard,
removeSliceFromDashboard,
runQuery,
logEvent,
},
dispatch,
),

View File

@ -33,6 +33,8 @@ import {
handleComponentDrop,
} from '../actions/dashboardLayout';
import { logEvent } from '../../logger/actions';
const propTypes = {
component: componentShape.isRequired,
parentComponent: componentShape.isRequired,
@ -40,6 +42,7 @@ const propTypes = {
deleteComponent: PropTypes.func.isRequired,
updateComponents: PropTypes.func.isRequired,
handleComponentDrop: PropTypes.func.isRequired,
logEvent: PropTypes.func.isRequired,
};
function mapStateToProps(
@ -80,6 +83,7 @@ function mapDispatchToProps(dispatch) {
deleteComponent,
updateComponents,
handleComponentDrop,
logEvent,
},
dispatch,
);

View File

@ -48,6 +48,8 @@ import {
addWarningToast,
} from '../../messageToasts/actions';
import { logEvent } from '../../logger/actions';
import { DASHBOARD_HEADER_ID } from '../util/constants';
function mapStateToProps({
@ -98,6 +100,7 @@ function mapDispatchToProps(dispatch) {
onSave: saveDashboardRequest,
setMaxUndoHistoryExceeded,
maxUndoHistoryToast,
logEvent,
},
dispatch,
);

View File

@ -24,6 +24,7 @@ import thunk from 'redux-thunk';
import { initFeatureFlags } from 'src/featureFlags';
import { initEnhancer } from '../reduxUtils';
import logger from '../middleware/loggerMiddleware';
import ToastPresenter from '../messageToasts/containers/ToastPresenter';
import ExploreViewContainer from './components/ExploreViewContainer';
import getInitialState from './reducers/getInitialState';
@ -46,7 +47,7 @@ const store = createStore(
rootReducer,
initState,
compose(
applyMiddleware(thunk),
applyMiddleware(thunk, logger),
initEnhancer(false),
),
);

View File

@ -34,7 +34,11 @@ import * as exploreActions from '../actions/exploreActions';
import * as saveModalActions from '../actions/saveModalActions';
import * as chartActions from '../../chart/chartAction';
import { fetchDatasourceMetadata } from '../../dashboard/actions/datasources';
import { Logger, ActionLog, EXPLORE_EVENT_NAMES, LOG_ACTIONS_MOUNT_EXPLORER } from '../../logger';
import * as logActions from '../../logger/actions/';
import {
LOG_ACTIONS_MOUNT_EXPLORER,
LOG_ACTIONS_CHANGE_EXPLORE_CONTROLS,
} from '../../logger/LogUtils';
import Hotkeys from '../../components/Hotkeys';
// Prolly need to move this to a global context
@ -72,13 +76,6 @@ const propTypes = {
class ExploreViewContainer extends React.Component {
constructor(props) {
super(props);
this.loadingLog = new ActionLog({
impressionId: props.impressionId,
source: 'slice',
sourceId: props.slice ? props.slice.slice_id : 0,
eventNames: EXPLORE_EVENT_NAMES,
});
Logger.start(this.loadingLog);
this.state = {
height: this.getHeight(),
@ -102,19 +99,10 @@ class ExploreViewContainer extends React.Component {
window.addEventListener('popstate', this.handlePopstate);
document.addEventListener('keydown', this.handleKeydown);
this.addHistory({ isReplace: true });
Logger.append(LOG_ACTIONS_MOUNT_EXPLORER);
this.props.actions.logEvent(LOG_ACTIONS_MOUNT_EXPLORER);
}
componentWillReceiveProps(nextProps) {
const wasRendered =
['rendered', 'failed', 'stopped'].indexOf(this.props.chart.chartStatus) > -1;
const isRendered = ['rendered', 'failed', 'stopped'].indexOf(nextProps.chart.chartStatus) > -1;
if (nextProps.chart.id !== this.props.chart.id) {
this.loadingLog.sourceId = nextProps.chart.id;
}
if (!wasRendered && isRendered) {
Logger.send(this.loadingLog);
}
if (nextProps.controls.viz_type.value !== this.props.controls.viz_type.value) {
this.props.actions.resetControls();
this.props.actions.triggerQuery(true, this.props.chart.id);
@ -136,6 +124,7 @@ class ExploreViewContainer extends React.Component {
this.props.actions.renderTriggered(new Date().getTime(), this.props.chart.id);
}
if (this.hasQueryControlChanged(changedControlKeys, nextProps.controls)) {
this.props.actions.logEvent(LOG_ACTIONS_CHANGE_EXPLORE_CONTROLS);
this.setState({ chartIsStale: true, refreshOverlayVisible: true });
}
}
@ -399,7 +388,12 @@ function mapStateToProps(state) {
}
function mapDispatchToProps(dispatch) {
const actions = Object.assign({}, exploreActions, saveModalActions, chartActions);
const actions = Object.assign({},
exploreActions,
saveModalActions,
chartActions,
logActions,
);
return {
actions: bindActionCreators(actions, dispatch),
};

View File

@ -1,183 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/* eslint no-console: 0 */
import { SupersetClient } from '@superset-ui/connection';
// This creates an association between an eventName and the ActionLog instance so that
// Logger.append calls do not have to know about the appropriate ActionLog instance
const addEventHandlers = {};
export const Logger = {
start(log) {
// create a handler to handle adding each event type
log.eventNames.forEach((eventName) => {
if (!addEventHandlers[eventName]) {
addEventHandlers[eventName] = log.addEvent.bind(log);
} else {
// eslint-disable-next-line no-console
console.warn(`Duplicate event handler for event '${eventName}'`);
}
});
},
append(eventName, eventBody, sendNow) {
if (addEventHandlers[eventName]) {
addEventHandlers[eventName](eventName, eventBody, sendNow);
} else {
// eslint-disable-next-line no-console
console.warn(`No event handler for event '${eventName}'`);
}
},
end(log) {
this.send(log);
// remove handlers
log.eventNames.forEach((eventName) => {
if (addEventHandlers[eventName]) {
delete addEventHandlers[eventName];
}
});
},
send(log) {
const { impressionId, source, sourceId, events } = log;
let endpoint = '/superset/log/?explode=events';
// backend logs treat these request params as first-class citizens
if (source === 'dashboard') {
endpoint += `&dashboard_id=${sourceId}`;
} else if (source === 'slice') {
endpoint += `&slice_id=${sourceId}`;
}
const eventData = [];
for (const eventName in events) {
events[eventName].forEach((event) => {
eventData.push({
source,
source_id: sourceId,
event_name: eventName,
impression_id: impressionId,
...event,
});
});
}
SupersetClient.post({
endpoint,
postPayload: { events: eventData },
parseMethod: null,
});
// flush events for this logger
log.events = {}; // eslint-disable-line no-param-reassign
},
// note that this returns ms since page load, NOT ms since epoc
getTimestamp() {
return Math.round(window.performance.now());
},
};
export class ActionLog {
constructor({ impressionId, source, sourceId, sendNow, eventNames }) {
this.impressionId = impressionId;
this.source = source;
this.sourceId = sourceId;
this.eventNames = eventNames || [];
this.sendNow = sendNow || false;
this.events = {};
this.addEvent = this.addEvent.bind(this);
}
setAttribute(name, value) {
this[name] = value;
}
addEvent(eventName, eventBody, sendNow) {
if (sendNow) {
Logger.send({
...this,
// overwrite events so that Logger.send doesn't clear this.events
events: {
[eventName]: [
{
ts: new Date().getTime(),
start_offset: Logger.getTimestamp(),
...eventBody,
},
],
},
});
} else {
this.events[eventName] = this.events[eventName] || [];
this.events[eventName].push({
ts: new Date().getTime(),
start_offset: Logger.getTimestamp(),
...eventBody,
});
if (this.sendNow) {
Logger.send(this);
}
}
}
}
// Log event types ------------------------------------------------------------
export const LOG_ACTIONS_MOUNT_DASHBOARD = 'mount_dashboard';
export const LOG_ACTIONS_MOUNT_EXPLORER = 'mount_explorer';
export const LOG_ACTIONS_FIRST_DASHBOARD_LOAD = 'first_dashboard_load';
export const LOG_ACTIONS_LOAD_DASHBOARD_PANE = 'load_dashboard_pane';
export const LOG_ACTIONS_LOAD_CHART = 'load_chart_data';
export const LOG_ACTIONS_RENDER_CHART_CONTAINER = 'render_chart_container';
export const LOG_ACTIONS_RENDER_CHART = 'render_chart';
export const LOG_ACTIONS_REFRESH_CHART = 'force_refresh_chart';
export const LOG_ACTIONS_REFRESH_DASHBOARD = 'force_refresh_dashboard';
export const LOG_ACTIONS_EXPLORE_DASHBOARD_CHART = 'explore_dashboard_chart';
export const LOG_ACTIONS_EXPORT_CSV_DASHBOARD_CHART = 'export_csv_dashboard_chart';
export const LOG_ACTIONS_CHANGE_DASHBOARD_FILTER = 'change_dashboard_filter';
export const LOG_ACTIONS_OMNIBAR_TRIGGERED = 'omnibar_dashboard_triggered';
export const DASHBOARD_EVENT_NAMES = [
LOG_ACTIONS_MOUNT_DASHBOARD,
LOG_ACTIONS_FIRST_DASHBOARD_LOAD,
LOG_ACTIONS_LOAD_DASHBOARD_PANE,
LOG_ACTIONS_LOAD_CHART,
LOG_ACTIONS_RENDER_CHART,
LOG_ACTIONS_EXPLORE_DASHBOARD_CHART,
LOG_ACTIONS_REFRESH_CHART,
LOG_ACTIONS_EXPORT_CSV_DASHBOARD_CHART,
LOG_ACTIONS_CHANGE_DASHBOARD_FILTER,
LOG_ACTIONS_REFRESH_DASHBOARD,
LOG_ACTIONS_OMNIBAR_TRIGGERED,
];
export const EXPLORE_EVENT_NAMES = [
LOG_ACTIONS_MOUNT_EXPLORER,
LOG_ACTIONS_LOAD_CHART,
LOG_ACTIONS_RENDER_CHART,
LOG_ACTIONS_REFRESH_CHART,
LOG_ACTIONS_RENDER_CHART_CONTAINER,
];

View File

@ -0,0 +1,63 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
// Log event names ------------------------------------------------------------
export const LOG_ACTIONS_LOAD_CHART = 'load_chart';
export const LOG_ACTIONS_RENDER_CHART = 'render_chart';
export const LOG_ACTIONS_MOUNT_DASHBOARD = 'mount_dashboard';
export const LOG_ACTIONS_MOUNT_EXPLORER = 'mount_explorer';
export const LOG_ACTIONS_SELECT_DASHBOARD_TAB = 'select_dashboard_tab';
export const LOG_ACTIONS_RENDER_CHART_CONTAINER = 'render_chart_container';
export const LOG_ACTIONS_FORCE_REFRESH_CHART = 'force_refresh_chart';
export const LOG_ACTIONS_CHANGE_EXPLORE_CONTROLS = 'change_explore_controls';
export const LOG_ACTIONS_TOGGLE_EDIT_DASHBOARD = 'toggle_edit_dashboard';
export const LOG_ACTIONS_FORCE_REFRESH_DASHBOARD = 'force_refresh_dashboard';
export const LOG_ACTIONS_PERIODIC_RENDER_DASHBOARD = 'periodic_render_dashboard';
export const LOG_ACTIONS_EXPLORE_DASHBOARD_CHART = 'explore_dashboard_chart';
export const LOG_ACTIONS_EXPORT_CSV_DASHBOARD_CHART = 'export_csv_dashboard_chart';
export const LOG_ACTIONS_CHANGE_DASHBOARD_FILTER = 'change_dashboard_filter';
export const LOG_ACTIONS_OMNIBAR_TRIGGERED = 'omnibar_triggered';
// Log event types --------------------------------------------------------------
export const LOG_EVENT_TYPE_TIMING = new Set([
LOG_ACTIONS_LOAD_CHART,
LOG_ACTIONS_RENDER_CHART,
]);
export const LOG_EVENT_TYPE_USER = new Set([
LOG_ACTIONS_MOUNT_DASHBOARD,
LOG_ACTIONS_SELECT_DASHBOARD_TAB,
LOG_ACTIONS_EXPLORE_DASHBOARD_CHART,
LOG_ACTIONS_FORCE_REFRESH_CHART,
LOG_ACTIONS_EXPORT_CSV_DASHBOARD_CHART,
LOG_ACTIONS_CHANGE_DASHBOARD_FILTER,
LOG_ACTIONS_CHANGE_EXPLORE_CONTROLS,
LOG_ACTIONS_TOGGLE_EDIT_DASHBOARD,
LOG_ACTIONS_FORCE_REFRESH_DASHBOARD,
LOG_ACTIONS_OMNIBAR_TRIGGERED,
LOG_ACTIONS_MOUNT_EXPLORER,
LOG_ACTIONS_RENDER_CHART_CONTAINER,
]);
export const Logger = {
// note that this returns ms since page load, NOT ms since epoc
getTimestamp() {
return Math.round(window.performance.now());
},
};

View File

@ -0,0 +1,31 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
export const LOG_EVENT = 'LOG_EVENT';
export function logEvent(eventName, eventData) {
return dispatch => (
dispatch({
type: LOG_EVENT,
payload: {
eventName,
eventData,
},
})
);
}

View File

@ -0,0 +1,117 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
/* eslint-disable camelcase */
/* eslint prefer-const: 2 */
import shortid from 'shortid';
import { SupersetClient } from '@superset-ui/connection';
import { safeStringify } from '../utils/safeStringify';
import { LOG_EVENT } from '../logger/actions';
import { LOG_EVENT_TYPE_TIMING } from '../logger/LogUtils';
import DebouncedMessageQueue from '../utils/DebouncedMessageQueue';
const LOG_ENDPOINT = '/superset/log/?explode=events';
const sendBeacon = (events) => {
if (events.length <= 0) {
return;
}
let endpoint = LOG_ENDPOINT;
const { source, source_id } = events[0];
// backend logs treat these request params as first-class citizens
if (source === 'dashboard') {
endpoint += `&dashboard_id=${source_id}`;
} else if (source === 'slice') {
endpoint += `&slice_id=${source_id}`;
}
if (navigator.sendBeacon) {
const formData = new FormData();
formData.append('events', safeStringify(events));
navigator.sendBeacon(endpoint, formData);
} else {
SupersetClient.post({
endpoint,
postPayload: { events },
parseMethod: null,
});
}
};
// beacon API has data size limit = 2^16.
// assume avg each log entry has 2^6 characters
const MAX_EVENTS_PER_REQUEST = 1024;
const logMessageQueue = new DebouncedMessageQueue({
callback: sendBeacon,
sizeThreshold: MAX_EVENTS_PER_REQUEST,
delayThreshold: 1000,
});
let lastEventId = 0;
const loggerMiddleware = store => next => (action) => {
if (action.type !== LOG_EVENT) {
return next(action);
}
const { dashboardInfo, explore, impressionId } = store.getState();
let logMetadata = {
impression_id: impressionId,
version: 'v2',
};
if (dashboardInfo) {
logMetadata = {
source: 'dashboard',
source_id: dashboardInfo.id,
...logMetadata,
};
} else if (explore) {
logMetadata = {
source: 'slice',
source_id: explore.slice ? explore.slice.slice_id : 0,
...logMetadata,
};
}
const { eventName } = action.payload;
let { eventData = {} } = action.payload;
eventData = {
...logMetadata,
ts: new Date().getTime(),
event_name: eventName,
...eventData,
};
if (LOG_EVENT_TYPE_TIMING.has(eventName)) {
eventData = {
...eventData,
event_type: 'timing',
trigger_event: lastEventId,
};
} else {
lastEventId = shortid.generate();
eventData = {
...eventData,
event_type: 'user',
event_id: lastEventId,
};
}
logMessageQueue.append(eventData);
return eventData;
};
export default loggerMiddleware;

View File

@ -0,0 +1,47 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { debounce } from 'lodash';
class DebouncedMessageQueue {
constructor({ callback = () => {}, sizeThreshold = 1000, delayThreshold = 1000 }) {
this.queue = [];
this.sizeThreshold = sizeThreshold;
this.delayThrehold = delayThreshold;
this.trigger = debounce(this.trigger.bind(this), this.delayThrehold);
this.callback = callback;
}
append(eventData) {
this.queue.push(eventData);
this.trigger();
}
trigger() {
if (this.queue.length > 0) {
const events = this.queue.splice(0, this.sizeThreshold);
this.callback.call(null, events);
// If there are remaining items, call it again.
if (this.queue.length > 0) {
this.trigger();
}
}
}
}
export default DebouncedMessageQueue;

View File

@ -96,7 +96,7 @@ QUERY_SEARCH_LIMIT = 1000
WTF_CSRF_ENABLED = True
# Add endpoints that need to be exempt from CSRF protection
WTF_CSRF_EXEMPT_LIST = []
WTF_CSRF_EXEMPT_LIST = ['superset.views.core.log']
# Whether to run the web server in debug mode or not
DEBUG = os.environ.get('FLASK_ENV') == 'development'