Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement analytics in Nuxt #844

Merged
merged 28 commits into from
Mar 17, 2023
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
38f7041
Add Plausible to the default development environment
dhruvkb Mar 1, 2023
f78a345
Install dependency for `vue-plausible`
dhruvkb Mar 5, 2023
7850997
Configure Plausible in Nuxt
dhruvkb Mar 5, 2023
738e0f9
Create composable for firing events
dhruvkb Mar 5, 2023
77e4eb6
Record viewport dimensions in cookies
dhruvkb Mar 5, 2023
9e6628b
fixup! Create composable for firing events
dhruvkb Mar 6, 2023
a668c37
Create a utility to send events without `$plausible`
dhruvkb Mar 6, 2023
77715bf
Create a middleware to log `VIEW_PAGE` and `SERVER_RENDERED` events
dhruvkb Mar 6, 2023
e07796a
Fix failing tests
dhruvkb Mar 6, 2023
5309edc
Add RFC to use Plausible
dhruvkb Mar 7, 2023
34a74db
Merge branch 'main' of https://github.com/WordPress/openverse into pl…
dhruvkb Mar 7, 2023
c3ef0f4
Add info about cloud to Plausible RFC
dhruvkb Mar 7, 2023
49e6da1
Add a note on pricing
dhruvkb Mar 8, 2023
6f76bec
Add a colon before list.
dhruvkb Mar 8, 2023
087243f
Merge branch 'plausible' of https://github.com/WordPress/openverse in…
dhruvkb Mar 9, 2023
0512b9e
Proxy Plausible calls via the Nuxt server
dhruvkb Mar 9, 2023
1a4362a
Move static field outside runtime config
dhruvkb Mar 9, 2023
64623e0
Remove page-views middleware
dhruvkb Mar 9, 2023
e13457f
Remove function for sending events using Axios
dhruvkb Mar 9, 2023
7e06869
Remove code for storing dimensions in cookies
dhruvkb Mar 9, 2023
d0b441f
Simplify analytics composable
dhruvkb Mar 9, 2023
164e622
Drop redundant events
dhruvkb Mar 9, 2023
1adb039
Set `apiHost` instead of blank string which doesn't work
dhruvkb Mar 9, 2023
70f0dcd
Configure proxy using env var
dhruvkb Mar 10, 2023
5493ed9
Merge branch 'main' of https://github.com/WordPress/openverse into pl…
dhruvkb Mar 15, 2023
d2be96c
Toggle `plausible_ignore` with analytics flag
dhruvkb Mar 15, 2023
3466fe7
Include breakpoint in custom event props
dhruvkb Mar 15, 2023
2b41df4
Merge branch 'main' of https://github.com/WordPress/openverse into pl…
dhruvkb Mar 17, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion frontend/nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ const config: NuxtConfig = {
: undefined,
},
router: {
middleware: "middleware",
middleware: ["middleware", "page-views"],
},
components: [
{ path: "~/components", extensions: ["vue"], pathPrefix: false },
Expand Down Expand Up @@ -206,6 +206,7 @@ const config: NuxtConfig = {
"@nuxtjs/redirect-module",
"@nuxtjs/sentry",
"cookie-universal-nuxt",
"vue-plausible",
"~/modules/prometheus.ts",
// Sitemap must be last to ensure that even routes created by other modules are added
"@nuxtjs/sitemap",
Expand Down Expand Up @@ -328,6 +329,15 @@ const config: NuxtConfig = {
},
},
},
plausible: {
trackLocalhost: !isProd,
},
publicRuntimeConfig: {
plausible: {
domain: process.env.PLAUSIBLE_DOMAIN ?? "localhost",
apiHost: process.env.PLAUSIBLE_API_HOST ?? "http://localhost:50288",
},
},
}

export default config
3 changes: 2 additions & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,8 @@
"throttle-debounce": "^5.0.0",
"uuid": "^8.3.2",
"vue": "^2.7.10",
"vue-i18n": "^8.26.7"
"vue-i18n": "^8.26.7",
"vue-plausible": "^1.3.2"
},
"devDependencies": {
"@babel/core": "^7.20.12",
Expand Down
28 changes: 22 additions & 6 deletions frontend/src/components/VHomeGallery/VHomeGallery.vue
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
:class="idx >= imageCount ? 'hidden' : 'block'"
:style="{ '--delay': `${idx * 0.05}s` }"
:href="image.url"
@click="handleClick(image.identifier)"
>
<img
:height="dimens"
Expand All @@ -49,6 +50,7 @@ import { computed, defineComponent, PropType, ref } from "vue"
import { useContext, useRouter } from "@nuxtjs/composition-api"

import { useReducedMotion } from "~/composables/use-media-query"
import { useAnalytics } from "~/composables/use-analytics"
import useResizeObserver from "~/composables/use-resize-observer"

import VLink from "~/components/VLink.vue"
Expand Down Expand Up @@ -101,14 +103,18 @@ export default defineComponent({
const el = ref<HTMLElement | null>(null) // template ref
const { dimens: gridDimens } = useResizeObserver(el)

const imageSet = computed(() =>
props.set === "random"
? imageInfo.sets[Math.floor(Math.random() * imageInfo.sets.length)]
: imageInfo.sets.find((item) => (item.key = props.set)) ??
imageInfo.sets[0]
)
const imageList = computed(() => {
const imageSet =
props.set === "random"
? imageInfo.sets[Math.floor(Math.random() * imageInfo.sets.length)]
: imageInfo.sets.find((item) => (item.key = props.set))
return imageSet?.images.map((image, idx) => ({
return imageSet.value.images.map((image, idx) => ({
...image,
src: require(`~/assets/homepage_images/${imageSet.key}/${idx + 1}.png`),
src: require(`~/assets/homepage_images/${imageSet.value.key}/${
idx + 1
}.png`),
url: router.resolve(
app.localePath({
name: "image-id",
Expand All @@ -119,6 +125,14 @@ export default defineComponent({
})
const imageCount = computed(() => columnCount.value * rowCount)

const { sendCustomEvent } = useAnalytics()
const handleClick = (identifier: string) => {
sendCustomEvent("CLICK_HOME_GALLERY_IMAGE", {
set: imageSet.value.key,
identifier,
})
}

return {
el,

Expand All @@ -130,6 +144,8 @@ export default defineComponent({
imageList,

prefersReducedMotion,

handleClick,
}
},
})
Expand Down
162 changes: 162 additions & 0 deletions frontend/src/composables/use-analytics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import axios from "axios"
import { isRef } from "vue"
import { useContext } from "@nuxtjs/composition-api"

import { log } from "~/utils/console"

import type { Events, EventName } from "~/types/analytics"

import type { Context } from "@nuxt/types"

const OPENVERSE_UA =
"Openverse/0.1 (https://wordpress.org/openverse; openverse@wordpress.org)"

type ContextReturn = ReturnType<typeof useContext>

/**
* Get Plausible props that work identically on the server-side and the
* client-side. This excludes props that need `window`.
*
* @param $ua - the User-Agent information from `express-useragent`
* @param i18n - the Nuxt i18n instance from `@nuxtjs/i18n`
*/
export const getIsomorphicProps = ({ $ua, i18n }: Context | ContextReturn) => ({
timestamp: new Date().toISOString(),
language: i18n.locale,
...($ua
? {
ua: $ua.source,
os: $ua.os,
platform: $ua.platform,
browser: $ua.browser,
version: $ua.version,
}
: {}),
})

/**
* Get Plausible props that work only on the client-side. This only includes
* props that need `window`.
*/
export const getWindowProps = () => ({
origin: window.location.origin,
pathname: window.location.pathname,
referrer: window.document.referrer,
width: window.innerWidth,
height: window.innerHeight,
})

/**
* Get Plausible props present in `window` through alternate channels. This uses
* Nuxt context fields that are only present on the server-side.
*
* @param req - the request received by the Nuxt SSR server
* @param route - the Vue router route being rendered from `vue-router`
* @param $cookies - the cookies object from `cookie-universal-nuxt`
*/
export const getPseudoWindowProps = ({
req,
route,
$cookies,
}: Context | ContextReturn) => ({
origin: req.headers.host ?? "", // `Host` header includes port.
pathname: isRef(route) ? route.value.fullPath : route.fullPath,
referrer: req.headers.referer ?? "",
width: $cookies.get("uiDeviceWidth") ?? -1,
height: $cookies.get("uiDeviceHeight") ?? -1,
})

/**
* The `ctx` parameter must be supplied if using this composable outside the
* bounds of the composition API.
*
* @param ctx - the Nuxt context
*/
export const useAnalytics = (ctx?: Context) => {
const context = ctx ?? useContext()
const { $cookies, $plausible, route, req } = context

/**
* Send a custom event to Plausible. Mandatory props are automatically merged
* with the event-specific props.
*
* @param name - the name of the event being recorded
* @param payload - the additional information to record about the event
*/
const sendCustomEvent = <T extends EventName>(
name: T,
payload: Events[T]
) => {
$plausible.trackEvent(name, {
props: {
...getIsomorphicProps(context),
...getWindowProps(),
...payload, // can override mandatory props
},
})
}

/**
* Send a custom event to Plausible. Mandatory props are automatically merged
* with the event-specific props. This function is meant to be used on the
* server side because it uses their API instead of using the integration.
*
* @param name - the name of the event being recorded
* @param payload - the additional information to record about the event
*/
const sendCustomEventApi = async <T extends EventName>(
sarayourfriend marked this conversation as resolved.
Show resolved Hide resolved
name: T,
payload: Events[T]
) => {
const origin = req.headers.host ?? "" // `Host` header includes port.
const pathname = isRef(route) ? route.value.fullPath : route.fullPath
const protocol =
origin.includes("localhost") || origin.match(/(\d{1,3}\.){3}\d{1,3}/)
? "http:"
: "https:"
const url = `${protocol}//${origin}${pathname}`

const referrer = req.headers.referer ?? ""

const width = $cookies.get("uiDeviceWidth") ?? -1

const xFFHeader = req.headers["x-forwarded-for"]
const xForwardedFor = Array.isArray(xFFHeader) // If multiple `X-Forwarded-For`...
? xFFHeader[0] // ...pick first.
: xFFHeader // If single `X-Forwarded-For`...
? xFFHeader // ...use it.
: req.socket.remoteAddress // If remote address known...
? req.socket.remoteAddress // ...use it.
: ""

const res = await axios.post(
"/api/event",
{
domain: process.env.PLAUSIBLE_DOMAIN ?? "localhost",
name, // Event name
url,
referrer,
width,
props: {
...getIsomorphicProps(context),
...getPseudoWindowProps(context),
...payload,
},
},
{
baseURL: process.env.PLAUSIBLE_API_HOST ?? "http://localhost:50288",
headers: {
"User-Agent": req.headers["user-agent"] ?? OPENVERSE_UA,
Referrer: referrer,
"X-Forwarded-For": xForwardedFor,
},
}
)
log(`Sent ${name}; Received ${res.status}: ${res.data}`)
}

return {
sendCustomEvent,
sendCustomEventApi,
}
}
14 changes: 9 additions & 5 deletions frontend/src/composables/use-layout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,17 +18,21 @@ const widthToBreakpoint = (width: number): Breakpoint => {
export function useLayout() {
const uiStore = useUiStore()

const { width } = useWindowSize()
const { width, height } = useWindowSize()

const updateBreakpoint = () => {
uiStore.updateBreakpoint(widthToBreakpoint(width.value))
uiStore.updateBreakpoint(
width.value,
height.value,
widthToBreakpoint(width.value)
)
}

watchThrottled(
width,
(newWidth) => {
[width, height],
([newWidth, newHeight]) => {
const newBp = widthToBreakpoint(newWidth)
uiStore.updateBreakpoint(newBp)
uiStore.updateBreakpoint(newWidth, newHeight, newBp)
},
{ throttle: 100 }
)
Expand Down
36 changes: 36 additions & 0 deletions frontend/src/middleware/page-views.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { useAnalytics } from "~/composables/use-analytics"

import type { Middleware } from "@nuxt/types"

/**
* This middleware sends events to Plausible.
*
* - For the initial page load, executed on the server, it sends a
* `SERVER_RENDERED` event.
* - For subsequent page loads, executed on the client, it sends a
* `VIEW_PAGE` event between every navigation.
*
* These events are in addition to the `pageview` events automatically sent by
* the Plausible integration.
sarayourfriend marked this conversation as resolved.
Show resolved Hide resolved
*
* @param context - the Nuxt context
*/
const pageViews: Middleware = async (context) => {
const { from, route } = context

if (process.client) {
const { sendCustomEvent } = useAnalytics(context)
sendCustomEvent("VIEW_PAGE", {
name: route.name ?? "<no name>",
pathname: route.fullPath, // Overrides `pathname` from mandatory payload.
fromPathname: from.fullPath,
})
} else {
const { sendCustomEventApi } = useAnalytics(context)
await sendCustomEventApi("SERVER_RENDERED", {
name: route.name ?? "<no name>",
})
}
}

export default pageViews
Loading