Add setting to send diagnostics back to SN (#463)

* Add diagnostics settings & endpoint

Stackers can now help us to identify and fix bugs by enabling diagnostics.

This will send anonymized data to us.

For now, this is only used to send events around push notifications.

* Send diagnostics to slack

* Detect OS

* Diagnostics data is only pseudonymous, not anonymous

It's only pseudonymous since with additional knowledge (which stacker uses which fancy name), we could trace the events back to individual stackers.

Data is only anonymous if this is not possible - it must be irreversible.

* Check if window.navigator is defined

* Use Slack SDK

* Catch errors of slack requests

---------

Co-authored-by: ekzyis <ek@stacker.news>
Co-authored-by: Keyan <34140557+huumn@users.noreply.github.com>
This commit is contained in:
ekzyis 2023-09-19 01:00:16 +02:00 committed by GitHub
parent e5852ee0b1
commit 3a7c3f7af2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 754 additions and 21 deletions

View File

@ -66,3 +66,7 @@ DATABASE_URL="postgresql://sn:password@db:5432/stackernews?schema=public"
POSTGRES_PASSWORD=password
POSTGRES_USER=sn
POSTGRES_DB=stackernews
# slack
SLACK_BOT_TOKEN=
SLACK_CHANNEL_ID=

17
api/slack/index.js Normal file
View File

@ -0,0 +1,17 @@
import { WebClient, LogLevel } from '@slack/web-api'
const slackClient = global.slackClient || (() => {
if (!process.env.SLACK_BOT_TOKEN && !process.env.SLACK_CHANNEL_ID) {
console.warn('SLACK_* env vars not set, skipping slack setup')
return null
}
console.log('initing slack client')
const client = new WebClient(process.env.SLACK_BOT_TOKEN, {
logLevel: LogLevel.INFO
})
return client
})()
if (process.env.NODE_ENV === 'development') global.slackClient = slackClient
export default slackClient

View File

@ -25,7 +25,7 @@ export default gql`
noteInvites: Boolean!, noteJobIndicator: Boolean!, noteCowboyHat: Boolean!, hideInvoiceDesc: Boolean!,
hideFromTopUsers: Boolean!, hideCowboyHat: Boolean!, clickToLoadImg: Boolean!,
wildWestMode: Boolean!, greeterMode: Boolean!, nostrPubkey: String, nostrRelays: [String!], hideBookmarks: Boolean!,
noteForwardedSats: Boolean!, hideWalletBalance: Boolean!, hideIsContributor: Boolean!): User
noteForwardedSats: Boolean!, hideWalletBalance: Boolean!, hideIsContributor: Boolean!, diagnostics: Boolean!): User
setPhoto(photoId: ID!): Int!
upsertBio(bio: String!): User!
setWalkthrough(tipPopover: Boolean, upvotePopover: Boolean): Boolean
@ -87,6 +87,7 @@ export default gql`
hideBookmarks: Boolean!
hideWelcomeBanner: Boolean!
hideWalletBalance: Boolean!
diagnostics: Boolean!
clickToLoadImg: Boolean!
wildWestMode: Boolean!
greeterMode: Boolean!

109
components/logger.js Normal file
View File

@ -0,0 +1,109 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react'
import { useMe } from './me'
import fancyNames from '../lib/fancy-names.json'
const generateFancyName = () => {
// 100 adjectives * 100 nouns * 10000 = 100M possible names
const pickRandom = (array) => array[Math.floor(Math.random() * array.length)]
const adj = pickRandom(fancyNames.adjectives)
const noun = pickRandom(fancyNames.nouns)
const id = Math.floor(Math.random() * fancyNames.maxSuffix)
return `${adj}-${noun}-${id}`
}
function detectOS () {
if (!window.navigator) return ''
const userAgent = window.navigator.userAgent
const platform = window.navigator.userAgentData?.platform || window.navigator.platform
const macosPlatforms = ['Macintosh', 'MacIntel', 'MacPPC', 'Mac68K']
const windowsPlatforms = ['Win32', 'Win64', 'Windows', 'WinCE']
const iosPlatforms = ['iPhone', 'iPad', 'iPod']
let os = null
if (macosPlatforms.indexOf(platform) !== -1) {
os = 'Mac OS'
} else if (iosPlatforms.indexOf(platform) !== -1) {
os = 'iOS'
} else if (windowsPlatforms.indexOf(platform) !== -1) {
os = 'Windows'
} else if (/Android/.test(userAgent)) {
os = 'Android'
} else if (/Linux/.test(platform)) {
os = 'Linux'
}
return os
}
const LoggerContext = createContext()
export function LoggerProvider ({ children }) {
const me = useMe()
const [name, setName] = useState()
const [os, setOS] = useState()
useEffect(() => {
let name = window.localStorage.getItem('fancy-name')
if (!name) {
name = generateFancyName()
window.localStorage.setItem('fancy-name', name)
}
setName(name)
setOS(detectOS())
}, [])
const log = useCallback(level => {
return async (message, context) => {
if (!me || !me.diagnostics) return
const env = {
userAgent: window.navigator.userAgent,
// os may not be initialized yet
os: os || detectOS()
}
const body = {
level,
env,
// name may be undefined if it wasn't stored in local storage yet
// we fallback to local storage since on page reloads, the name may wasn't fetched from local storage yet
name: name || window.localStorage.getItem('fancy-name'),
message,
context
}
await fetch('/api/log', {
method: 'post',
headers: {
'Content-type': 'application/json'
},
body: JSON.stringify(body)
}).catch(console.error)
}
}, [me?.diagnostics, name, os])
const logger = useMemo(() => ({
info: log('info'),
warn: log('warn'),
error: log('error'),
name
}), [log, name])
useEffect(() => {
// for communication between app and service worker
const channel = new MessageChannel()
navigator?.serviceWorker?.controller?.postMessage({ action: 'MESSAGE_PORT' }, [channel.port2])
channel.port1.onmessage = (event) => {
const { message, level, context } = Object.assign({ level: 'info' }, event.data)
logger[level](message, context)
}
}, [logger])
return (
<LoggerContext.Provider value={logger}>
{children}
</LoggerContext.Provider>
)
}
export function useLogger () {
return useContext(LoggerContext)
}

View File

@ -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 './logger'
const applicationServerKey = process.env.NEXT_PUBLIC_VAPID_PUBKEY
@ -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.
@ -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.info('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`
@ -68,24 +72,30 @@ export const ServiceWorkerProvider = ({ children }) => {
action: 'STORE_SUBSCRIPTION',
subscription: pushSubscription
})
logger.info('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.info('sent push subscription to server', { endpoint })
}
const unsubscribeFromPushNotifications = async (subscription) => {
await subscription.unsubscribe()
const { endpoint } = subscription
logger.info('unsubscribed from push notifications', { endpoint })
await deletePushSubscription({ variables: { endpoint } })
logger.info('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()
})
@ -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.info('sent SYNC_SUBSCRIPTION to service worker')
}, [])
useEffect(() => {
if (!support.serviceWorker) return
if (!support.serviceWorker) {
logger.info('device does not support service worker')
return
}
const wb = new Workbox('/sw.js', { scope: '/' })
wb.register().then(registration => {
logger.info('service worker registration successful')
setRegistration(registration)
})
}, [support.serviceWorker])

View File

@ -31,6 +31,7 @@ export const ME = gql`
hideFromTopUsers
hideCowboyHat
clickToLoadImg
diagnostics
wildWestMode
greeterMode
lastCheckedJobs
@ -62,6 +63,7 @@ export const SETTINGS_FIELDS = gql`
hideIsContributor
clickToLoadImg
hideWalletBalance
diagnostics
nostrPubkey
nostrRelays
wildWestMode
@ -91,14 +93,14 @@ mutation setSettings($tipDefault: Int!, $turboTipping: Boolean!, $fiatCurrency:
$noteInvites: Boolean!, $noteJobIndicator: Boolean!, $noteCowboyHat: Boolean!, $hideInvoiceDesc: Boolean!,
$hideFromTopUsers: Boolean!, $hideCowboyHat: Boolean!, $clickToLoadImg: Boolean!,
$wildWestMode: Boolean!, $greeterMode: Boolean!, $nostrPubkey: String, $nostrRelays: [String!], $hideBookmarks: Boolean!,
$noteForwardedSats: Boolean!, $hideWalletBalance: Boolean!, $hideIsContributor: Boolean!) {
$noteForwardedSats: Boolean!, $hideWalletBalance: Boolean!, $hideIsContributor: Boolean!, $diagnostics: 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,
noteForwardedSats: $noteForwardedSats, hideWalletBalance: $hideWalletBalance, hideIsContributor: $hideIsContributor) {
noteForwardedSats: $noteForwardedSats, hideWalletBalance: $hideWalletBalance, hideIsContributor: $hideIsContributor, diagnostics: $diagnostics) {
...SettingsFields
}
}

207
lib/fancy-names.json Normal file
View File

@ -0,0 +1,207 @@
{
"adjectives": [
"mighty",
"radiant",
"whimsical",
"cosmic",
"enchanted",
"electric",
"serene",
"vibrant",
"fierce",
"mystical",
"playful",
"daring",
"soothing",
"galactic",
"exuberant",
"harmonious",
"energetic",
"tranquil",
"sparkling",
"majestic",
"luminous",
"brave",
"blissful",
"captivating",
"ethereal",
"dynamic",
"spirited",
"graceful",
"magical",
"adventurous",
"resplendent",
"serendipitous",
"tenacious",
"whirlwind",
"jubilant",
"enigmatic",
"mystifying",
"zephyr",
"celestial",
"enthralling",
"curious",
"infinite",
"radiant",
"mesmerizing",
"vibrant",
"euphoric",
"awe-inspiring",
"phenomenal",
"serene",
"bewitching",
"impulsive",
"thrilling",
"eclectic",
"vivacious",
"spirited",
"enchanting",
"dynamic",
"brilliant",
"harmonic",
"charismatic",
"courageous",
"tenacious",
"lively",
"bewildering",
"whimsical",
"enveloping",
"playful",
"captivating",
"inspiring",
"zenith",
"majestic",
"dazzling",
"resilient",
"celestial",
"resplendent",
"enigmatic",
"tranquil",
"ethereal",
"exquisite",
"radiating",
"breathtaking",
"rhapsodic",
"melodic",
"phenomenal",
"enchanted",
"invincible",
"serendipitous",
"kaleidoscopic",
"intriguing",
"spellbinding",
"thriving",
"thrilling",
"reverie",
"exhilarating",
"invigorating",
"resolute",
"audacious",
"empowering",
"jubilant",
"timeless"
],
"nouns": [
"summer",
"phoenix",
"echo",
"voyager",
"starlight",
"harmony",
"nova",
"dreamer",
"cascade",
"celestial",
"dragon",
"whisper",
"serenade",
"avalanche",
"pinnacle",
"odyssey",
"enigma",
"zenith",
"mirage",
"symphony",
"nebula",
"infinity",
"serenity",
"radiance",
"horizon",
"eclipse",
"solstice",
"aria",
"tornado",
"aurora",
"mirage",
"quasar",
"cascade",
"seraph",
"nebula",
"solitude",
"paradigm",
"infinity",
"melody",
"nebula",
"radiance",
"odyssey",
"seraph",
"melody",
"aria",
"zenith",
"eclipse",
"tornado",
"solstice",
"celestia",
"phoenix",
"voyager",
"starlight",
"dreamer",
"cascade",
"aurora",
"serenity",
"echo",
"serenade",
"pinnacle",
"symphony",
"harmony",
"quasar",
"horizon",
"enigma",
"mirage",
"nebula",
"solitude",
"radiance",
"odyssey",
"zenith",
"aria",
"melody",
"celestia",
"seraph",
"infinity",
"eclipse",
"tornado",
"aurora",
"paradigm",
"solstice",
"phoenix",
"voyager",
"starlight",
"dreamer",
"cascade",
"nebula",
"serenade",
"pinnacle",
"symphony",
"harmony",
"zenith",
"mirage",
"eclipse",
"quasar",
"radiance",
"serenity",
"aurora",
"tornado",
"horizon"
],
"maxSuffix": 10000
}

View File

@ -225,6 +225,7 @@ export const settingsSchema = object({
({ max, value }) => `${Math.abs(max - value.length)} too many`),
hideBookmarks: boolean(),
hideWalletBalance: boolean(),
diagnostics: boolean(),
hideIsContributor: boolean()
})

267
package-lock.json generated
View File

@ -16,6 +16,7 @@
"@noble/curves": "^1.1.0",
"@opensearch-project/opensearch": "^2.3.1",
"@prisma/client": "^5.1.1",
"@slack/web-api": "^6.9.0",
"acorn": "^8.10.0",
"ajv": "^8.12.0",
"async-retry": "^1.3.1",
@ -3071,6 +3072,54 @@
"url": "https://github.com/sindresorhus/is?sponsor=1"
}
},
"node_modules/@slack/logger": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@slack/logger/-/logger-3.0.0.tgz",
"integrity": "sha512-DTuBFbqu4gGfajREEMrkq5jBhcnskinhr4+AnfJEk48zhVeEv3XnUKGIX98B74kxhYsIMfApGGySTn7V3b5yBA==",
"dependencies": {
"@types/node": ">=12.0.0"
},
"engines": {
"node": ">= 12.13.0",
"npm": ">= 6.12.0"
}
},
"node_modules/@slack/types": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@slack/types/-/types-2.8.0.tgz",
"integrity": "sha512-ghdfZSF0b4NC9ckBA8QnQgC9DJw2ZceDq0BIjjRSv6XAZBXJdWgxIsYz0TYnWSiqsKZGH2ZXbj9jYABZdH3OSQ==",
"engines": {
"node": ">= 12.13.0",
"npm": ">= 6.12.0"
}
},
"node_modules/@slack/web-api": {
"version": "6.9.0",
"resolved": "https://registry.npmjs.org/@slack/web-api/-/web-api-6.9.0.tgz",
"integrity": "sha512-RME5/F+jvQmZHkoP+ogrDbixq1Ms1mBmylzuWq4sf3f7GCpMPWoiZ+WqWk+sism3vrlveKWIgO9R4Qg9fiRyoQ==",
"dependencies": {
"@slack/logger": "^3.0.0",
"@slack/types": "^2.8.0",
"@types/is-stream": "^1.1.0",
"@types/node": ">=12.0.0",
"axios": "^0.27.2",
"eventemitter3": "^3.1.0",
"form-data": "^2.5.0",
"is-electron": "2.2.2",
"is-stream": "^1.1.0",
"p-queue": "^6.6.1",
"p-retry": "^4.0.0"
},
"engines": {
"node": ">= 12.13.0",
"npm": ">= 6.12.0"
}
},
"node_modules/@slack/web-api/node_modules/eventemitter3": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-3.1.2.tgz",
"integrity": "sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q=="
},
"node_modules/@surma/rollup-plugin-off-main-thread": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz",
@ -3312,6 +3361,14 @@
"resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.1.tgz",
"integrity": "sha512-SZs7ekbP8CN0txVG2xVRH6EgKmEm31BOxA07vkFaETzZz1xh+cbt8BcI0slpymvwhx5dlFnQG2rTlPVQn+iRPQ=="
},
"node_modules/@types/is-stream": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@types/is-stream/-/is-stream-1.1.0.tgz",
"integrity": "sha512-jkZatu4QVbR60mpIzjINmtS1ZF4a/FqdTUTBeQDVOQ2PYyidtwFKr0B5G6ERukKwliq+7mIXvxyppwzG5EgRYg==",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@types/json-schema": {
"version": "7.0.12",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.12.tgz",
@ -3446,6 +3503,11 @@
"@types/node": "*"
}
},
"node_modules/@types/retry": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz",
"integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA=="
},
"node_modules/@types/scheduler": {
"version": "0.16.2",
"resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.2.tgz",
@ -4073,6 +4135,28 @@
"resolved": "https://registry.npmjs.org/aws4/-/aws4-1.12.0.tgz",
"integrity": "sha512-NmWvPnx0F1SfrQbYwOi7OeaNGokp9XhzNioJ/CSBs8Qa4vxug81mhJEAVZwxXuBmYB5KDRfMq/F3RR0BIU7sWg=="
},
"node_modules/axios": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.27.2.tgz",
"integrity": "sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ==",
"dependencies": {
"follow-redirects": "^1.14.9",
"form-data": "^4.0.0"
}
},
"node_modules/axios/node_modules/form-data": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
"integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
"dependencies": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"mime-types": "^2.1.12"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/babel-plugin-inline-react-svg": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/babel-plugin-inline-react-svg/-/babel-plugin-inline-react-svg-2.0.2.tgz",
@ -7121,6 +7205,25 @@
"integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==",
"dev": true
},
"node_modules/follow-redirects": {
"version": "1.15.2",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz",
"integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA==",
"funding": [
{
"type": "individual",
"url": "https://github.com/sponsors/RubenVerborgh"
}
],
"engines": {
"node": ">=4.0"
},
"peerDependenciesMeta": {
"debug": {
"optional": true
}
}
},
"node_modules/for-each": {
"version": "0.3.3",
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz",
@ -8268,6 +8371,11 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/is-electron": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/is-electron/-/is-electron-2.2.2.tgz",
"integrity": "sha512-FO/Rhvz5tuw4MCWkpMzHFKWD2LsfHzIb7i6MdPYZ/KW7AlxawyLkqdy+jPZP1WubqEADE3O4FUENlJHDfQASRg=="
},
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
@ -13116,6 +13224,14 @@
"node": ">=8"
}
},
"node_modules/p-finally": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz",
"integrity": "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow==",
"engines": {
"node": ">=4"
}
},
"node_modules/p-limit": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
@ -13175,6 +13291,44 @@
"url": "https://github.com/sindresorhus/p-memoize?sponsor=1"
}
},
"node_modules/p-queue": {
"version": "6.6.2",
"resolved": "https://registry.npmjs.org/p-queue/-/p-queue-6.6.2.tgz",
"integrity": "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==",
"dependencies": {
"eventemitter3": "^4.0.4",
"p-timeout": "^3.2.0"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/p-retry": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz",
"integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==",
"dependencies": {
"@types/retry": "0.12.0",
"retry": "^0.13.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/p-timeout": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz",
"integrity": "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==",
"dependencies": {
"p-finally": "^1.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
@ -20218,6 +20372,44 @@
"resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz",
"integrity": "sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw=="
},
"@slack/logger": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/@slack/logger/-/logger-3.0.0.tgz",
"integrity": "sha512-DTuBFbqu4gGfajREEMrkq5jBhcnskinhr4+AnfJEk48zhVeEv3XnUKGIX98B74kxhYsIMfApGGySTn7V3b5yBA==",
"requires": {
"@types/node": ">=12.0.0"
}
},
"@slack/types": {
"version": "2.8.0",
"resolved": "https://registry.npmjs.org/@slack/types/-/types-2.8.0.tgz",
"integrity": "sha512-ghdfZSF0b4NC9ckBA8QnQgC9DJw2ZceDq0BIjjRSv6XAZBXJdWgxIsYz0TYnWSiqsKZGH2ZXbj9jYABZdH3OSQ=="
},
"@slack/web-api": {
"version": "6.9.0",
"resolved": "https://registry.npmjs.org/@slack/web-api/-/web-api-6.9.0.tgz",
"integrity": "sha512-RME5/F+jvQmZHkoP+ogrDbixq1Ms1mBmylzuWq4sf3f7GCpMPWoiZ+WqWk+sism3vrlveKWIgO9R4Qg9fiRyoQ==",
"requires": {
"@slack/logger": "^3.0.0",
"@slack/types": "^2.8.0",
"@types/is-stream": "^1.1.0",
"@types/node": ">=12.0.0",
"axios": "^0.27.2",
"eventemitter3": "^3.1.0",
"form-data": "^2.5.0",
"is-electron": "2.2.2",
"is-stream": "^1.1.0",
"p-queue": "^6.6.1",
"p-retry": "^4.0.0"
},
"dependencies": {
"eventemitter3": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-3.1.2.tgz",
"integrity": "sha512-tvtQIeLVHjDkJYnzf2dgVMxfuSGJeM/7UCG17TT4EumTfNtF+0nebF/4zWOIkCreAbtNqhGEboB6BWrwqNaw4Q=="
}
}
},
"@surma/rollup-plugin-off-main-thread": {
"version": "2.2.3",
"resolved": "https://registry.npmjs.org/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz",
@ -20449,6 +20641,14 @@
"resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.1.tgz",
"integrity": "sha512-SZs7ekbP8CN0txVG2xVRH6EgKmEm31BOxA07vkFaETzZz1xh+cbt8BcI0slpymvwhx5dlFnQG2rTlPVQn+iRPQ=="
},
"@types/is-stream": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@types/is-stream/-/is-stream-1.1.0.tgz",
"integrity": "sha512-jkZatu4QVbR60mpIzjINmtS1ZF4a/FqdTUTBeQDVOQ2PYyidtwFKr0B5G6ERukKwliq+7mIXvxyppwzG5EgRYg==",
"requires": {
"@types/node": "*"
}
},
"@types/json-schema": {
"version": "7.0.12",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.12.tgz",
@ -20582,6 +20782,11 @@
"@types/node": "*"
}
},
"@types/retry": {
"version": "0.12.0",
"resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz",
"integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA=="
},
"@types/scheduler": {
"version": "0.16.2",
"resolved": "https://registry.npmjs.org/@types/scheduler/-/scheduler-0.16.2.tgz",
@ -21109,6 +21314,27 @@
"resolved": "https://registry.npmjs.org/aws4/-/aws4-1.12.0.tgz",
"integrity": "sha512-NmWvPnx0F1SfrQbYwOi7OeaNGokp9XhzNioJ/CSBs8Qa4vxug81mhJEAVZwxXuBmYB5KDRfMq/F3RR0BIU7sWg=="
},
"axios": {
"version": "0.27.2",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.27.2.tgz",
"integrity": "sha512-t+yRIyySRTp/wua5xEr+z1q60QmLq8ABsS5O9Me1AsE5dfKqgnCFzwiCZZ/cGNd1lq4/7akDWMxdhVlucjmnOQ==",
"requires": {
"follow-redirects": "^1.14.9",
"form-data": "^4.0.0"
},
"dependencies": {
"form-data": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz",
"integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==",
"requires": {
"asynckit": "^0.4.0",
"combined-stream": "^1.0.8",
"mime-types": "^2.1.12"
}
}
}
},
"babel-plugin-inline-react-svg": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/babel-plugin-inline-react-svg/-/babel-plugin-inline-react-svg-2.0.2.tgz",
@ -23331,6 +23557,11 @@
"integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==",
"dev": true
},
"follow-redirects": {
"version": "1.15.2",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.2.tgz",
"integrity": "sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA=="
},
"for-each": {
"version": "0.3.3",
"resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz",
@ -24142,6 +24373,11 @@
"resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-1.0.4.tgz",
"integrity": "sha512-RGdriMmQQvZ2aqaQq3awNA6dCGtKpiDFcOzrTWrDAT2MiWrKQVPmxLGHl7Y2nNu6led0kEyoX0enY0qXYsv9zw=="
},
"is-electron": {
"version": "2.2.2",
"resolved": "https://registry.npmjs.org/is-electron/-/is-electron-2.2.2.tgz",
"integrity": "sha512-FO/Rhvz5tuw4MCWkpMzHFKWD2LsfHzIb7i6MdPYZ/KW7AlxawyLkqdy+jPZP1WubqEADE3O4FUENlJHDfQASRg=="
},
"is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
@ -27140,6 +27376,11 @@
"resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-2.1.1.tgz",
"integrity": "sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg=="
},
"p-finally": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz",
"integrity": "sha512-LICb2p9CB7FS+0eR1oqWnHhp0FljGLZCWBE9aix0Uye9W8LTQPwMTYVGWQWIw9RdQiDg4+epXQODwIYJtSJaow=="
},
"p-limit": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
@ -27175,6 +27416,32 @@
"type-fest": "^3.0.0"
}
},
"p-queue": {
"version": "6.6.2",
"resolved": "https://registry.npmjs.org/p-queue/-/p-queue-6.6.2.tgz",
"integrity": "sha512-RwFpb72c/BhQLEXIZ5K2e+AhgNVmIejGlTgiB9MzZ0e93GRvqZ7uSi0dvRF7/XIXDeNkra2fNHBxTyPDGySpjQ==",
"requires": {
"eventemitter3": "^4.0.4",
"p-timeout": "^3.2.0"
}
},
"p-retry": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz",
"integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==",
"requires": {
"@types/retry": "0.12.0",
"retry": "^0.13.1"
}
},
"p-timeout": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/p-timeout/-/p-timeout-3.2.0.tgz",
"integrity": "sha512-rhIwUycgwwKcP9yTOOFK/AKsAopjjCakVqLHePO3CC6Mir1Z99xT+R63jZxAT5lFZLa2inS5h+ZS2GvR99/FBg==",
"requires": {
"p-finally": "^1.0.0"
}
},
"p-try": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",

View File

@ -18,6 +18,7 @@
"@noble/curves": "^1.1.0",
"@opensearch-project/opensearch": "^2.3.1",
"@prisma/client": "^5.1.1",
"@slack/web-api": "^6.9.0",
"acorn": "^8.10.0",
"ajv": "^8.12.0",
"async-retry": "^1.3.1",

View File

@ -16,6 +16,7 @@ import { ServiceWorkerProvider } from '../components/serviceworker'
import { SSR } from '../lib/constants'
import NProgress from 'nprogress'
import 'nprogress/nprogress.css'
import { LoggerProvider } from '../components/logger'
NProgress.configure({
showSpinner: false
@ -88,19 +89,21 @@ function MyApp ({ Component, pageProps: { ...props } }) {
<PlausibleProvider domain='stacker.news' trackOutboundLinks>
<ApolloProvider client={client}>
<MeProvider me={me}>
<ServiceWorkerProvider>
<PriceProvider price={price}>
<LightningProvider>
<ToastProvider>
<ShowModalProvider>
<BlockHeightProvider blockHeight={blockHeight}>
<Component ssrData={ssrData} {...otherProps} />
</BlockHeightProvider>
</ShowModalProvider>
</ToastProvider>
</LightningProvider>
</PriceProvider>
</ServiceWorkerProvider>
<LoggerProvider>
<ServiceWorkerProvider>
<PriceProvider price={price}>
<LightningProvider>
<ToastProvider>
<ShowModalProvider>
<BlockHeightProvider blockHeight={blockHeight}>
<Component ssrData={ssrData} {...otherProps} />
</BlockHeightProvider>
</ShowModalProvider>
</ToastProvider>
</LightningProvider>
</PriceProvider>
</ServiceWorkerProvider>
</LoggerProvider>
</MeProvider>
</ApolloProvider>
</PlausibleProvider>

26
pages/api/log/index.js Normal file
View File

@ -0,0 +1,26 @@
import models from '../../../api/models'
import slackClient from '../../../api/slack'
const channelId = process.env.SLACK_CHANNEL_ID
const toKV = (obj) => {
return obj ? Object.entries(obj).reduce((text, [k, v]) => text + ` ${k}=${v}`, '').trimStart() : '-'
}
const slackPostMessage = ({ id, level, name, message, env, context }) => {
if (!slackClient) return
const text = `\`${new Date().toISOString()}\` | \`${id} [${level}] ${name}\` | ${message} | ${toKV(context)} | ${toKV({ os: env.os })}`
return slackClient.chat.postMessage({ channel: channelId, text })
}
export default async (req, res) => {
const { level, name, message, env, context } = req.body
if (!name) return res.status(400).json({ status: 400, message: 'name required' })
if (!message) return res.status(400).json({ status: 400, message: 'message required' })
const { id } = await models.log.create({ data: { level: level.toUpperCase(), name, message, env, context } })
slackPostMessage({ id, ...req.body }).catch(console.error)
return res.status(200).json({ status: 200, message: 'ok' })
}

View File

@ -23,6 +23,7 @@ import { useShowModal } from '../components/modal'
import { authErrorMessage } from '../components/login'
import { NostrAuth } from '../components/nostr-auth'
import { useToast } from '../components/toast'
import { useLogger } from '../components/logger'
import { useMe } from '../components/me'
export const getServerSideProps = getGetServerSideProps({ query: SETTINGS, authRequired: true })
@ -47,6 +48,7 @@ export default function Settings ({ ssrData }) {
}
}
)
const logger = useLogger()
const { data } = useQuery(SETTINGS)
const { settings } = data || ssrData
@ -80,6 +82,7 @@ export default function Settings ({ ssrData }) {
nostrRelays: settings?.nostrRelays?.length ? settings?.nostrRelays : [''],
hideBookmarks: settings?.hideBookmarks,
hideWalletBalance: settings?.hideWalletBalance,
diagnostics: settings?.diagnostics,
hideIsContributor: settings?.hideIsContributor
}}
schema={settingsSchema}
@ -253,6 +256,29 @@ 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'>allow diagnostics
<Info>
<ul className='fw-bold'>
<li>collect and send back diagnostics data</li>
<li>this information is used to identify and fix bugs</li>
<li>this information includes:
<ul><li>timestamps</li></ul>
<ul><li>a randomly generated fancy name</li></ul>
<ul><li>your user agent</li></ul>
<ul><li>your operating system</li></ul>
</li>
<li>this information can not be traced back to you without your fancy name</li>
<li>fancy names are generated in your browser</li>
</ul>
<div className='text-muted fst-italic'>your fancy name: {logger.name}</div>
</Info>
</div>
}
name='diagnostics'
/>
<div className='form-label'>content</div>
<Checkbox

View File

@ -0,0 +1,21 @@
-- CreateEnum
CREATE TYPE "LogLevel" AS ENUM ('DEBUG', 'INFO', 'WARN', 'ERROR');
-- AlterTable
ALTER TABLE "users" ADD COLUMN "diagnostics" BOOLEAN NOT NULL DEFAULT false;
-- CreateTable
CREATE TABLE "Log" (
"id" SERIAL NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"level" "LogLevel" NOT NULL,
"name" TEXT NOT NULL,
"message" TEXT NOT NULL,
"env" JSONB,
"context" JSONB,
CONSTRAINT "Log_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "Log.name_index" ON "Log"("created_at", "name");

View File

@ -90,6 +90,7 @@ model User {
followers UserSubscription[] @relation("follower")
followees UserSubscription[] @relation("followee")
hideWelcomeBanner Boolean @default(false)
diagnostics Boolean @default(false)
hideIsContributor Boolean @default(false)
@@index([createdAt], map: "users.created_at_index")
@ -561,6 +562,18 @@ model PushSubscription {
@@index([userId], map: "PushSubscription.userId_index")
}
model Log {
id Int @id @default(autoincrement())
createdAt DateTime @default(now()) @map("created_at")
level LogLevel
name String
message String
env Json?
context Json?
@@index([createdAt, name], map: "Log.name_index")
}
enum EarnType {
POST
COMMENT
@ -606,3 +619,10 @@ enum WithdrawlStatus {
CONFIRMED
UNKNOWN_FAILURE
}
enum LogLevel {
DEBUG
INFO
WARN
ERROR
}

View File

@ -11,6 +11,7 @@ import ServiceWorkerStorage from 'serviceworker-storage'
self.__WB_DISABLE_DEV_LOGS = true
const storage = new ServiceWorkerStorage('sw:storage', 1)
let messageChannelPort
// preloading improves startup performance
// https://developer.chrome.com/docs/workbox/modules/workbox-navigation-preload/
@ -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`
messageChannelPort?.postMessage({ level: 'error', message })
console.error(message)
return null
}
if (notifications.length === 0) {
@ -85,7 +88,12 @@ self.addEventListener('notificationclick', (event) => {
// https://medium.com/@madridserginho/how-to-handle-webpush-api-pushsubscriptionchange-event-in-modern-browsers-6e47840d756f
self.addEventListener('message', (event) => {
if (event.data.action === 'MESSAGE_PORT') {
messageChannelPort = event.ports[0]
}
messageChannelPort?.postMessage({ message: '[sw:message] received message', context: { action: event.data.action } })
if (event.data.action === 'STORE_SUBSCRIPTION') {
messageChannelPort?.postMessage({ message: '[sw:message] storing subscription in IndexedDB', context: { endpoint: event.data.subscription.endpoint } })
return event.waitUntil(storage.setItem('subscription', event.data.subscription))
}
if (event.data.action === 'SYNC_SUBSCRIPTION') {
@ -96,14 +104,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
messageChannelPort?.postMessage({ message: '[sw:handlePushSubscriptionChange] invoked' })
oldSubscription ??= await storage.getItem('subscription')
newSubscription ??= await self.registration.pushManager.getSubscription()
if (!newSubscription) {
// no subscription exists at the moment
messageChannelPort?.postMessage({ message: '[sw:handlePushSubscriptionChange] no existing subscription found' })
return
}
if (oldSubscription?.endpoint === newSubscription.endpoint) {
// subscription did not change. no need to sync with server
messageChannelPort?.postMessage({ message: '[sw:handlePushSubscriptionChange] old subscription matches existing subscription' })
return
}
// convert keys from ArrayBuffer to string
@ -128,9 +139,11 @@ async function handlePushSubscriptionChange (oldSubscription, newSubscription) {
},
body
})
messageChannelPort?.postMessage({ message: '[sw:handlePushSubscriptionChange] synced push subscription with server', context: { endpoint: variables.endpoint, oldEndpoint: variables.oldEndpoint } })
await storage.setItem('subscription', JSON.parse(JSON.stringify(newSubscription)))
}
self.addEventListener('pushsubscriptionchange', (event) => {
messageChannelPort?.postMessage({ message: '[sw:pushsubscriptionchange] received event' })
event.waitUntil(handlePushSubscriptionChange(event.oldSubscription, event.newSubscription))
}, false)