Provide analytics on Front-end
Signed-off-by: Thomas Citharel <tcit@tcit.fr>
This commit is contained in:
@@ -203,6 +203,16 @@ export default class App extends Vue {
|
||||
this.interval = undefined;
|
||||
}
|
||||
|
||||
@Watch("config")
|
||||
async initializeStatistics(config: IConfig) {
|
||||
if (config) {
|
||||
const { statistics } = (await import("./services/statistics")) as {
|
||||
statistics: (config: IConfig, environment: Record<string, any>) => void;
|
||||
};
|
||||
statistics(config, { router: this.$router, version: config.version });
|
||||
}
|
||||
}
|
||||
|
||||
@Watch("$route", { immediate: true })
|
||||
updateAnnouncement(route: Route): void {
|
||||
const pageTitle = this.extractPageTitleFromRoute(route);
|
||||
|
||||
@@ -48,13 +48,75 @@
|
||||
$t("Mobilizon")
|
||||
}}</a>
|
||||
</i18n>
|
||||
{{
|
||||
$t(
|
||||
"We improve this software thanks to your feedback. To let us know about this issue, two possibilities (both unfortunately require user account creation):"
|
||||
)
|
||||
}}
|
||||
<span v-if="sentryEnabled && sentryReady">
|
||||
{{
|
||||
$t(
|
||||
"We collect your feedback and the error information in order to improve this service."
|
||||
)
|
||||
}}</span
|
||||
>
|
||||
<span v-else>
|
||||
{{
|
||||
$t(
|
||||
"We improve this software thanks to your feedback. To let us know about this issue, two possibilities (both unfortunately require user account creation):"
|
||||
)
|
||||
}}
|
||||
</span>
|
||||
</p>
|
||||
<div class="content">
|
||||
<form
|
||||
v-if="sentryEnabled && sentryReady && !submittedFeedback"
|
||||
@submit.prevent="sendErrorToSentry"
|
||||
>
|
||||
<b-field :label="$t('What happened?')" label-for="what-happened">
|
||||
<b-input
|
||||
v-model="feedback"
|
||||
type="textarea"
|
||||
id="what-happened"
|
||||
:placeholder="$t(`I've clicked on X, then on Y`)"
|
||||
/>
|
||||
</b-field>
|
||||
<b-button icon-left="send" native-type="submit" type="is-primary">{{
|
||||
$t("Send feedback")
|
||||
}}</b-button>
|
||||
<p class="content">
|
||||
{{
|
||||
$t(
|
||||
"Please add as many details as possible to help identify the problem."
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
</form>
|
||||
<b-message type="is-danger" v-else-if="feedbackError">
|
||||
<p>
|
||||
{{
|
||||
$t(
|
||||
"Sorry, we wen't able to save your feedback. Don't worry, we'll try to fix this issue anyway."
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<i18n path="You may now close this page or {return_to_the_homepage}.">
|
||||
<template #return_to_the_homepage>
|
||||
<router-link :to="{ name: RouteName.HOME }">{{
|
||||
$t("return to the homepage")
|
||||
}}</router-link>
|
||||
</template>
|
||||
</i18n>
|
||||
</b-message>
|
||||
<b-message type="is-success" v-else-if="submittedFeedback">
|
||||
<p>{{ $t("Thanks a lot, your feedback was submitted!") }}</p>
|
||||
<i18n path="You may now close this page or {return_to_the_homepage}.">
|
||||
<template #return_to_the_homepage>
|
||||
<router-link :to="{ name: RouteName.HOME }">{{
|
||||
$t("return to the homepage")
|
||||
}}</router-link>
|
||||
</template>
|
||||
</i18n>
|
||||
</b-message>
|
||||
<div
|
||||
class="content"
|
||||
v-if="!(sentryEnabled && sentryReady) || submittedFeedback"
|
||||
>
|
||||
<p v-if="submittedFeedback">{{ $t("You may also:") }}</p>
|
||||
<ul>
|
||||
<li>
|
||||
<a
|
||||
@@ -65,7 +127,7 @@
|
||||
</li>
|
||||
<li>
|
||||
<a
|
||||
href="https://framagit.org/framasoft/mobilizon/-/issues/new?issuable_template=Bug"
|
||||
href="https://framagit.org/framasoft/mobilizon/-/issues/"
|
||||
target="_blank"
|
||||
>{{
|
||||
$t("Open an issue on our bug tracker (advanced users)")
|
||||
@@ -74,7 +136,7 @@
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<p class="content">
|
||||
<p class="content" v-if="!sentryEnabled">
|
||||
{{
|
||||
$t(
|
||||
"Please add as many details as possible to help identify the problem."
|
||||
@@ -89,14 +151,14 @@
|
||||
<p>{{ $t("Error stacktrace") }}</p>
|
||||
<pre>{{ error.stack }}</pre>
|
||||
</details>
|
||||
<p>
|
||||
<p v-if="!sentryEnabled">
|
||||
{{
|
||||
$t(
|
||||
"The technical details of the error can help developers solve the problem more easily. Please add them to your feedback."
|
||||
)
|
||||
}}
|
||||
</p>
|
||||
<div class="buttons">
|
||||
<div class="buttons" v-if="!sentryEnabled">
|
||||
<b-tooltip
|
||||
:label="tooltipConfig.label"
|
||||
:type="tooltipConfig.type"
|
||||
@@ -115,14 +177,20 @@
|
||||
</div>
|
||||
</template>
|
||||
<script lang="ts">
|
||||
import { CONTACT } from "@/graphql/config";
|
||||
import { CONFIG } from "@/graphql/config";
|
||||
import { checkProviderConfig, convertConfig } from "@/services/statistics";
|
||||
import { IAnalyticsConfig, IConfig } from "@/types/config.model";
|
||||
import { Component, Prop, Vue } from "vue-property-decorator";
|
||||
import { LOGGED_USER } from "@/graphql/user";
|
||||
import { IUser } from "@/types/current-user.model";
|
||||
import { ISentryConfiguration } from "@/types/analytics/sentry.model";
|
||||
import { submitFeedback } from "@/services/statistics/sentry";
|
||||
import RouteName from "@/router/name";
|
||||
|
||||
@Component({
|
||||
apollo: {
|
||||
config: {
|
||||
query: CONTACT,
|
||||
},
|
||||
config: CONFIG,
|
||||
loggedUser: LOGGED_USER,
|
||||
},
|
||||
metaInfo() {
|
||||
return {
|
||||
@@ -138,7 +206,17 @@ export default class ErrorComponent extends Vue {
|
||||
|
||||
copied: "success" | "error" | false = false;
|
||||
|
||||
config!: { contact: string | null; name: string };
|
||||
config!: IConfig;
|
||||
|
||||
feedback = "";
|
||||
|
||||
submittedFeedback = false;
|
||||
|
||||
feedbackError = false;
|
||||
|
||||
loggedUser!: IUser;
|
||||
|
||||
RouteName = RouteName;
|
||||
|
||||
async copyErrorToClipboard(): Promise<void> {
|
||||
try {
|
||||
@@ -193,6 +271,56 @@ export default class ErrorComponent extends Vue {
|
||||
|
||||
document.body.removeChild(textArea);
|
||||
}
|
||||
|
||||
get sentryEnabled(): boolean {
|
||||
return this.sentryProvider?.enabled === true;
|
||||
}
|
||||
|
||||
get sentryProvider(): IAnalyticsConfig | undefined {
|
||||
return this.config && checkProviderConfig(this.config, "sentry");
|
||||
}
|
||||
|
||||
get sentryConfig(): ISentryConfiguration | undefined {
|
||||
if (this.sentryProvider?.configuration) {
|
||||
return convertConfig(
|
||||
this.sentryProvider?.configuration
|
||||
) as ISentryConfiguration;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
get sentryReady() {
|
||||
const eventId = window.sessionStorage.getItem("lastEventId");
|
||||
const dsn = this.sentryConfig?.dsn;
|
||||
const organization = this.sentryConfig?.organization;
|
||||
const project = this.sentryConfig?.project;
|
||||
const host = this.sentryConfig?.host;
|
||||
return eventId && dsn && organization && project && host;
|
||||
}
|
||||
|
||||
async sendErrorToSentry() {
|
||||
try {
|
||||
const eventId = window.sessionStorage.getItem("lastEventId");
|
||||
const dsn = this.sentryConfig?.dsn;
|
||||
const organization = this.sentryConfig?.organization;
|
||||
const project = this.sentryConfig?.project;
|
||||
const host = this.sentryConfig?.host;
|
||||
const endpoint = `https://${host}/api/0/projects/${organization}/${project}/user-feedback/`;
|
||||
if (eventId && dsn && this.sentryReady) {
|
||||
await submitFeedback(endpoint, dsn, {
|
||||
event_id: eventId,
|
||||
name:
|
||||
this.loggedUser?.defaultActor?.preferredUsername || "Unknown user",
|
||||
email: this.loggedUser?.email || "unknown@email.org",
|
||||
comments: this.feedback,
|
||||
});
|
||||
this.submittedFeedback = true;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
this.feedbackError = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@@ -96,6 +96,15 @@ export const CONFIG = gql`
|
||||
enabled
|
||||
publicKey
|
||||
}
|
||||
analytics {
|
||||
id
|
||||
enabled
|
||||
configuration {
|
||||
key
|
||||
value
|
||||
type
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
@@ -1309,5 +1309,14 @@
|
||||
"Reset filters": "Reset filters",
|
||||
"Category": "Category",
|
||||
"Select a category": "Select a category",
|
||||
"Any category": "Any category"
|
||||
"Any category": "Any category",
|
||||
"We collect your feedback and the error information in order to improve this service.": "We collect your feedback and the error information in order to improve this service.",
|
||||
"What happened?": "What happened?",
|
||||
"I've clicked on X, then on Y": "I've clicked on X, then on Y",
|
||||
"Send feedback": "Send feedback",
|
||||
"Sorry, we wen't able to save your feedback. Don't worry, we'll try to fix this issue anyway.": "Sorry, we wen't able to save your feedback. Don't worry, we'll try to fix this issue anyway.",
|
||||
"return to the homepage": "return to the homepage",
|
||||
"Thanks a lot, your feedback was submitted!": "Thanks a lot, your feedback was submitted!",
|
||||
"You may also:": "You may also:",
|
||||
"You may now close this page or {return_to_the_homepage}.": "You may now close this page or {return_to_the_homepage}."
|
||||
}
|
||||
|
||||
50
js/src/services/statistics/index.ts
Normal file
50
js/src/services/statistics/index.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import {
|
||||
IAnalyticsConfig,
|
||||
IConfig,
|
||||
IKeyValueConfig,
|
||||
} from "@/types/config.model";
|
||||
|
||||
export const statistics = async (config: IConfig, environement: any) => {
|
||||
console.debug("Loading statistics", config.analytics);
|
||||
const matomoConfig = checkProviderConfig(config, "matomo");
|
||||
if (matomoConfig?.enabled === true) {
|
||||
const { matomo } = (await import("./matomo")) as any;
|
||||
matomo(environement, convertConfig(matomoConfig.configuration));
|
||||
}
|
||||
|
||||
const sentryConfig = checkProviderConfig(config, "sentry");
|
||||
if (sentryConfig?.enabled === true) {
|
||||
const { sentry } = (await import("./sentry")) as any;
|
||||
sentry(environement, convertConfig(sentryConfig.configuration));
|
||||
}
|
||||
};
|
||||
|
||||
export const checkProviderConfig = (
|
||||
config: IConfig,
|
||||
providerName: string
|
||||
): IAnalyticsConfig | undefined => {
|
||||
return config?.analytics?.find((provider) => provider.id === providerName);
|
||||
};
|
||||
|
||||
export const convertConfig = (
|
||||
configs: IKeyValueConfig[]
|
||||
): Record<string, any> => {
|
||||
return configs.reduce((acc, config) => {
|
||||
acc[config.key] = toType(config.value, config.type);
|
||||
return acc;
|
||||
}, {} as Record<string, any>);
|
||||
};
|
||||
|
||||
const toType = (value: string, type: string): string | number | boolean => {
|
||||
switch (type) {
|
||||
case "boolean":
|
||||
return value === "true";
|
||||
case "integer":
|
||||
return parseInt(value, 10);
|
||||
case "float":
|
||||
return parseFloat(value);
|
||||
case "string":
|
||||
default:
|
||||
return value;
|
||||
}
|
||||
};
|
||||
14
js/src/services/statistics/matomo.ts
Normal file
14
js/src/services/statistics/matomo.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import Vue from "vue";
|
||||
import VueMatomo from "vue-matomo";
|
||||
|
||||
export const matomo = (environment: any, matomoConfiguration: any) => {
|
||||
console.debug("Loading Matomo statistics");
|
||||
console.debug(
|
||||
"Calling VueMatomo with the following configuration",
|
||||
matomoConfiguration
|
||||
);
|
||||
Vue.use(VueMatomo, {
|
||||
...matomoConfiguration,
|
||||
router: environment.router,
|
||||
});
|
||||
};
|
||||
11
js/src/services/statistics/plausible.ts
Normal file
11
js/src/services/statistics/plausible.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import VueRouter from "vue-router";
|
||||
import Vue from "vue";
|
||||
import { VuePlausible } from "vue-plausible";
|
||||
export default (router: VueRouter, plausibleConfiguration: any) => {
|
||||
console.debug("Loading Plausible statistics");
|
||||
|
||||
Vue.use(VuePlausible, {
|
||||
// see configuration section
|
||||
...plausibleConfiguration,
|
||||
});
|
||||
};
|
||||
54
js/src/services/statistics/sentry.ts
Normal file
54
js/src/services/statistics/sentry.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import Vue from "vue";
|
||||
|
||||
import * as Sentry from "@sentry/vue";
|
||||
import { Integrations } from "@sentry/tracing";
|
||||
|
||||
export const sentry = (environment: any, sentryConfiguration: any) => {
|
||||
console.debug("Loading Sentry statistics");
|
||||
console.debug(
|
||||
"Calling Sentry with the following configuration",
|
||||
sentryConfiguration
|
||||
);
|
||||
// Don't attach errors to previous events
|
||||
window.sessionStorage.removeItem("lastEventId");
|
||||
Sentry.init({
|
||||
Vue,
|
||||
dsn: sentryConfiguration.dsn,
|
||||
integrations: [
|
||||
new Integrations.BrowserTracing({
|
||||
routingInstrumentation: Sentry.vueRouterInstrumentation(
|
||||
environment.router
|
||||
),
|
||||
tracingOrigins: ["localhost", "mobilizon1.com", /^\//],
|
||||
}),
|
||||
],
|
||||
beforeSend(event) {
|
||||
// Check if it is an exception, and if so, save it in session storage
|
||||
// so that it can be retreived from the error component
|
||||
if (event.exception && event.event_id) {
|
||||
window.sessionStorage.setItem("lastEventId", event.event_id);
|
||||
}
|
||||
return event;
|
||||
},
|
||||
// Set tracesSampleRate to 1.0 to capture 100%
|
||||
// of transactions for performance monitoring.
|
||||
// We recommend adjusting this value in production
|
||||
tracesSampleRate: sentryConfiguration.tracesSampleRate,
|
||||
release: environment.version,
|
||||
});
|
||||
};
|
||||
|
||||
export const submitFeedback = async (
|
||||
endpoint: string,
|
||||
dsn: string,
|
||||
params: Record<string, string>
|
||||
): Promise<void> => {
|
||||
await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-type": "application/json",
|
||||
Authorization: `DSN ${dsn}`,
|
||||
},
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
};
|
||||
7
js/src/types/analytics/sentry.model.ts
Normal file
7
js/src/types/analytics/sentry.model.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export interface ISentryConfiguration {
|
||||
dsn: string;
|
||||
organization?: string;
|
||||
project?: string;
|
||||
host?: string;
|
||||
tracesSampleRate: number;
|
||||
}
|
||||
@@ -6,6 +6,18 @@ export interface IOAuthProvider {
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface IKeyValueConfig {
|
||||
key: string;
|
||||
value: string;
|
||||
type: "boolean" | "integer" | "string";
|
||||
}
|
||||
|
||||
export interface IAnalyticsConfig {
|
||||
id: string;
|
||||
enabled: boolean;
|
||||
configuration: IKeyValueConfig[];
|
||||
}
|
||||
|
||||
export interface IConfig {
|
||||
name: string;
|
||||
description: string;
|
||||
@@ -110,4 +122,5 @@ export interface IConfig {
|
||||
exportFormats: {
|
||||
eventParticipants: string[];
|
||||
};
|
||||
analytics: IAnalyticsConfig[];
|
||||
}
|
||||
|
||||
1
js/src/typings/matomo.d.ts
vendored
Normal file
1
js/src/typings/matomo.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
declare module "vue-matomo";
|
||||
Reference in New Issue
Block a user