Skip to content

Commit

Permalink
wip: client-side logging
Browse files Browse the repository at this point in the history
  • Loading branch information
ekzyis committed Aug 31, 2023
1 parent ac45fdc commit 47b551f
Show file tree
Hide file tree
Showing 11 changed files with 176 additions and 20 deletions.
4 changes: 3 additions & 1 deletion api/typeDefs/user.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,8 @@ export default gql`
noteEarning: Boolean!, noteAllDescendants: Boolean!, noteMentions: Boolean!, noteDeposits: Boolean!,
noteInvites: Boolean!, noteJobIndicator: Boolean!, noteCowboyHat: Boolean!, hideInvoiceDesc: Boolean!,
hideFromTopUsers: Boolean!, hideCowboyHat: Boolean!, clickToLoadImg: Boolean!,
wildWestMode: Boolean!, greeterMode: Boolean!, nostrPubkey: String, nostrRelays: [String!], hideBookmarks: Boolean!): User
wildWestMode: Boolean!, greeterMode: Boolean!, nostrPubkey: String, nostrRelays: [String!], hideBookmarks: Boolean!,
clientSideLogging: Boolean!): User
setPhoto(photoId: ID!): Int!
upsertBio(bio: String!): User!
setWalkthrough(tipPopover: Boolean, upvotePopover: Boolean): Boolean
Expand Down Expand Up @@ -81,6 +82,7 @@ export default gql`
hideFromTopUsers: Boolean!
hideCowboyHat: Boolean!
hideBookmarks: Boolean!
clientSideLogging: Boolean!
clickToLoadImg: Boolean!
wildWestMode: Boolean!
greeterMode: Boolean!
Expand Down
55 changes: 55 additions & 0 deletions components/log.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { createContext, useCallback, useContext, useEffect } from 'react'
import { useMe } from './me'

const LoggerContext = createContext()

export function LoggerProvider ({ children }) {
const me = useMe()

const logLevel = useCallback(level => {
return async (event) => {
if (!me?.clientSideLogging) return
const env = {
userAgent: window.navigator.userAgent
}
const body = {
level,
env,
event: typeof event === 'string' ? { message: event } : event
}
await fetch('/api/log', {
method: 'post',
headers: {
'Content-type': 'application/json'
},
body: JSON.stringify(body)
}).catch(console.error)
}
}, [me?.clientSideLogging])

const logger = {
log: logLevel('log'),
warn: logLevel('warn'),
error: logLevel('error')
}

useEffect(() => {
const channel = new BroadcastChannel('log')
channel.onmessage = event => {
const level = event.data?.level || 'log'
delete event.data?.level
logger[level](event.data)
}
return () => channel.close()
}, [logger])

return (
<LoggerContext.Provider value={logger}>
{children}
</LoggerContext.Provider>
)
}

export function useLogger () {
return useContext(LoggerContext)
}
21 changes: 18 additions & 3 deletions components/serviceworker.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { createContext, useContext, useEffect, useState, useCallback } from 'react'
import { Workbox } from 'workbox-window'
import { gql, useMutation } from '@apollo/client'
import { useLogger } from './log'

const applicationServerKey = process.env.NEXT_PUBLIC_VAPID_PUBKEY

Expand Down Expand Up @@ -34,6 +35,7 @@ export const ServiceWorkerProvider = ({ children }) => {
}
}
`)
const logger = useLogger()

// I am not entirely sure if this is needed since at least in Brave,
// using `registration.pushManager.subscribe` also prompts the user.
Expand All @@ -60,6 +62,8 @@ export const ServiceWorkerProvider = ({ children }) => {
// Brave users must enable a flag in brave://settings/privacy first
// see https://stackoverflow.com/a/69624651
let pushSubscription = await registration.pushManager.subscribe(subscribeOptions)
const { endpoint } = pushSubscription
logger.log({ message: 'subscribed to push notifications', endpoint })
// convert keys from ArrayBuffer to string
pushSubscription = JSON.parse(JSON.stringify(pushSubscription))
// Send subscription to service worker to save it so we can use it later during `pushsubscriptionchange`
Expand All @@ -68,24 +72,30 @@ export const ServiceWorkerProvider = ({ children }) => {
action: 'STORE_SUBSCRIPTION',
subscription: pushSubscription
})
logger.log({ message: 'sent STORE_SUBSCRIPTION to service worker', endpoint })
// send subscription to server
const variables = {
endpoint: pushSubscription.endpoint,
endpoint,
p256dh: pushSubscription.keys.p256dh,
auth: pushSubscription.keys.auth
}
await savePushSubscription({ variables })
logger.log('sent push subscription to server', { endpoint })
}

const unsubscribeFromPushNotifications = async (subscription) => {
await subscription.unsubscribe()
const { endpoint } = subscription
logger.log({ message: 'unsubscribed from push notifications', endpoint })
await deletePushSubscription({ variables: { endpoint } })
logger.log({ message: 'deleted push subscription from server', endpoint })
}

const togglePushSubscription = useCallback(async () => {
const pushSubscription = await registration.pushManager.getSubscription()
if (pushSubscription) return unsubscribeFromPushNotifications(pushSubscription)
if (pushSubscription) {
return unsubscribeFromPushNotifications(pushSubscription)
}
return subscribeToPushNotifications()
})

Expand All @@ -100,12 +110,17 @@ export const ServiceWorkerProvider = ({ children }) => {
// we sync with server manually by checking on every page reload if the push subscription changed.
// see https://medium.com/@madridserginho/how-to-handle-webpush-api-pushsubscriptionchange-event-in-modern-browsers-6e47840d756f
navigator?.serviceWorker?.controller?.postMessage?.({ action: 'SYNC_SUBSCRIPTION' })
logger.log('sent SYNC_SUBSCRIPTION to service worker')
}, [])

useEffect(() => {
if (!support.serviceWorker) return
if (!support.serviceWorker) {
logger.log('device does not support service worker')
return
}
const wb = new Workbox('/sw.js', { scope: '/' })
wb.register().then(registration => {
logger.log('service worker registration successful')
setRegistration(registration)
})
}, [support.serviceWorker])
Expand Down
8 changes: 6 additions & 2 deletions fragments/users.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export const ME = gql`
hideFromTopUsers
hideCowboyHat
clickToLoadImg
clientSideLogging
wildWestMode
greeterMode
lastCheckedJobs
Expand All @@ -54,6 +55,7 @@ export const SETTINGS_FIELDS = gql`
hideCowboyHat
hideBookmarks
clickToLoadImg
clientSideLogging
nostrPubkey
nostrRelays
wildWestMode
Expand Down Expand Up @@ -82,13 +84,15 @@ mutation setSettings($tipDefault: Int!, $turboTipping: Boolean!, $fiatCurrency:
$noteEarning: Boolean!, $noteAllDescendants: Boolean!, $noteMentions: Boolean!, $noteDeposits: Boolean!,
$noteInvites: Boolean!, $noteJobIndicator: Boolean!, $noteCowboyHat: Boolean!, $hideInvoiceDesc: Boolean!,
$hideFromTopUsers: Boolean!, $hideCowboyHat: Boolean!, $clickToLoadImg: Boolean!,
$wildWestMode: Boolean!, $greeterMode: Boolean!, $nostrPubkey: String, $nostrRelays: [String!], $hideBookmarks: Boolean!) {
$wildWestMode: Boolean!, $greeterMode: Boolean!, $nostrPubkey: String, $nostrRelays: [String!], $hideBookmarks: Boolean!,
$clientSideLogging: Boolean!) {
setSettings(tipDefault: $tipDefault, turboTipping: $turboTipping, fiatCurrency: $fiatCurrency,
noteItemSats: $noteItemSats, noteEarning: $noteEarning, noteAllDescendants: $noteAllDescendants,
noteMentions: $noteMentions, noteDeposits: $noteDeposits, noteInvites: $noteInvites,
noteJobIndicator: $noteJobIndicator, noteCowboyHat: $noteCowboyHat, hideInvoiceDesc: $hideInvoiceDesc,
hideFromTopUsers: $hideFromTopUsers, hideCowboyHat: $hideCowboyHat, clickToLoadImg: $clickToLoadImg,
wildWestMode: $wildWestMode, greeterMode: $greeterMode, nostrPubkey: $nostrPubkey, nostrRelays: $nostrRelays, hideBookmarks: $hideBookmarks) {
wildWestMode: $wildWestMode, greeterMode: $greeterMode, nostrPubkey: $nostrPubkey, nostrRelays: $nostrRelays, hideBookmarks: $hideBookmarks,
clientSideLogging: $clientSideLogging) {
...SettingsFields
}
}
Expand Down
3 changes: 2 additions & 1 deletion lib/validate.js
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,8 @@ export const settingsSchema = object({
string().matches(WS_REGEXP, 'invalid web socket address')
).max(NOSTR_MAX_RELAY_NUM,
({ max, value }) => `${Math.abs(max - value.length)} too many`),
hideBookmarks: boolean()
hideBookmarks: boolean(),
clientSideLogging: boolean()
})

const warningMessage = 'If I logout, even accidentally, I will never be able to access my account again'
Expand Down
25 changes: 14 additions & 11 deletions pages/_app.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import { ServiceWorkerProvider } from '../components/serviceworker'
import { SSR } from '../lib/constants'
import NProgress from 'nprogress'
import 'nprogress/nprogress.css'
import { LoggerProvider } from '../components/log'

NProgress.configure({
showSpinner: false
Expand Down Expand Up @@ -87,17 +88,19 @@ function MyApp ({ Component, pageProps: { ...props } }) {
<PlausibleProvider domain='stacker.news' trackOutboundLinks>
<ApolloProvider client={client}>
<MeProvider me={me}>
<ServiceWorkerProvider>
<PriceProvider price={price}>
<LightningProvider>
<ToastProvider>
<ShowModalProvider>
<Component ssrData={ssrData} {...otherProps} />
</ShowModalProvider>
</ToastProvider>
</LightningProvider>
</PriceProvider>
</ServiceWorkerProvider>
<LoggerProvider>
<ServiceWorkerProvider>
<PriceProvider price={price}>
<LightningProvider>
<ToastProvider>
<ShowModalProvider>
<Component ssrData={ssrData} {...otherProps} />
</ShowModalProvider>
</ToastProvider>
</LightningProvider>
</PriceProvider>
</ServiceWorkerProvider>
</LoggerProvider>
</MeProvider>
</ApolloProvider>
</PlausibleProvider>
Expand Down
17 changes: 17 additions & 0 deletions pages/api/log/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { getServerSession } from 'next-auth'
import models from '../../../api/models'
import { getAuthOptions } from '../auth/[...nextauth]'

export default async (req, res) => {
const session = await getServerSession(req, res, getAuthOptions(req))

if (!session.user) return res.status(401).json({ status: 'unauthorized' })

// TODO:
// Should we scramble user IDs by hashing?
// If no key is involved, anyone with knowledge of user IDs could hash IDs and compare hashes.
// If a key is involved during hashing, only we can compare hashes of user IDs.
await models.log.create({ data: { data: { userId: session.user.id, ...req.body } } })

return res.status(200).json({ status: 'ok' })
}
33 changes: 32 additions & 1 deletion pages/settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,8 @@ export default function Settings ({ ssrData }) {
greeterMode: settings?.greeterMode,
nostrPubkey: settings?.nostrPubkey ? bech32encode(settings.nostrPubkey) : '',
nostrRelays: settings?.nostrRelays?.length ? settings?.nostrRelays : [''],
hideBookmarks: settings?.hideBookmarks
hideBookmarks: settings?.hideBookmarks,
clientSideLogging: settings?.clientSideLogging
}}
schema={settingsSchema}
onSubmit={async ({ tipDefault, nostrPubkey, nostrRelays, ...values }) => {
Expand Down Expand Up @@ -232,6 +233,36 @@ export default function Settings ({ ssrData }) {
<Checkbox
label={<>hide my bookmarks from other stackers</>}
name='hideBookmarks'
groupClassName='mb-0'
/>
<Checkbox
label={
<div className='d-flex align-items-center'>enable client-side logging
<Info>
<ul className='fw-bold'>
{
/** TODO: better disclaimer */
}
<li>capture and send log events back to SN</li>
<li>this information is used to identify and fix bugs</li>
<li>this information includes:
<ul><li>timestamps</li></ul>
<ul><li>your user id</li></ul>
<ul><li>your user agent</li></ul>
</li>
{
/**
* TODO: Should we anonymize data?
* <li><i>insert something about anonymization here</i></li>
* TODO: Should we make information we collect easily accessible?
* <li>all your information is available <Link href='/api/logs'>here</Link></li>
*/
}
</ul>
</Info>
</div>
}
name='clientSideLogging'
/>
<div className='form-label'>content</div>
<Checkbox
Expand Down
11 changes: 11 additions & 0 deletions prisma/migrations/20230831054014_client_side_logging/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
-- AlterTable
ALTER TABLE "users" ADD COLUMN "clientSideLogging" BOOLEAN NOT NULL DEFAULT false;

-- CreateTable
CREATE TABLE "Log" (
"id" SERIAL NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"data" JSONB NOT NULL,

CONSTRAINT "Log_pkey" PRIMARY KEY ("id")
);
7 changes: 7 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ model User {
hideBookmarks Boolean @default(false)
followers UserSubscription[] @relation("follower")
followees UserSubscription[] @relation("followee")
clientSideLogging Boolean @default(false)
@@index([createdAt], map: "users.created_at_index")
@@index([inviteId], map: "users.inviteId_index")
Expand Down Expand Up @@ -554,6 +555,12 @@ model PushSubscription {
@@index([userId], map: "PushSubscription.userId_index")
}

model Log {
id Int @id @default(autoincrement())
createdAt DateTime @default(now()) @map("created_at")
data Json
}

enum EarnType {
POST
COMMENT
Expand Down
12 changes: 11 additions & 1 deletion sw/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import ServiceWorkerStorage from 'serviceworker-storage'
self.__WB_DISABLE_DEV_LOGS = true

const storage = new ServiceWorkerStorage('sw:storage', 1)
const logChannel = new BroadcastChannel('log')

// preloading improves startup performance
// https://developer.chrome.com/docs/workbox/modules/workbox-navigation-preload/
Expand Down Expand Up @@ -55,7 +56,9 @@ self.addEventListener('push', async function (event) {
const notifications = await self.registration.getNotifications({ tag })
// since we used a tag filter, there should only be zero or one notification
if (notifications.length > 1) {
console.error(`more than one notification with tag ${tag} found`)
const message = `[sw:push] more than one notification with tag ${tag} found`
logChannel.log({ level: 'error', message })
console.error(message)
return null
}
if (notifications.length === 0) {
Expand Down Expand Up @@ -85,7 +88,9 @@ self.addEventListener('notificationclick', (event) => {

// https://medium.com/@madridserginho/how-to-handle-webpush-api-pushsubscriptionchange-event-in-modern-browsers-6e47840d756f
self.addEventListener('message', (event) => {
logChannel.postMessage({ message: '[sw:message] received message', action: event.data.action })
if (event.data.action === 'STORE_SUBSCRIPTION') {
logChannel.postMessage({ message: '[sw:message] storing subscription in IndexedDB', endpoint: event.data.subscription.endpoint })
return event.waitUntil(storage.setItem('subscription', event.data.subscription))
}
if (event.data.action === 'SYNC_SUBSCRIPTION') {
Expand All @@ -96,14 +101,17 @@ self.addEventListener('message', (event) => {
async function handlePushSubscriptionChange (oldSubscription, newSubscription) {
// https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerGlobalScope/pushsubscriptionchange_event
// fallbacks since browser may not set oldSubscription and newSubscription
logChannel.postMessage('[sw:handlePushSubscriptionChange] invoked')
oldSubscription ??= await storage.getItem('subscription')
newSubscription ??= await self.registration.pushManager.getSubscription()
if (!newSubscription) {
// no subscription exists at the moment
logChannel.postMessage('[sw:handlePushSubscriptionChange] no existing subscription found')
return
}
if (oldSubscription?.endpoint === newSubscription.endpoint) {
// subscription did not change. no need to sync with server
logChannel.postMessage('[sw:handlePushSubscriptionChange] old subscription matches existing subscription')
return
}
// convert keys from ArrayBuffer to string
Expand All @@ -128,9 +136,11 @@ async function handlePushSubscriptionChange (oldSubscription, newSubscription) {
},
body
})
logChannel.postMessage({ message: '[sw:handlePushSubscriptionChange] synced push subscription with server', endpoint: variables.endpoint, oldEndpoint: variables.oldEndpoint })
await storage.setItem('subscription', JSON.parse(JSON.stringify(newSubscription)))
}

self.addEventListener('pushsubscriptionchange', (event) => {
logChannel.postMessage('[sw:pushsubscriptionchange] received event')
event.waitUntil(handlePushSubscriptionChange(event.oldSubscription, event.newSubscription))
}, false)

0 comments on commit 47b551f

Please sign in to comment.