Browse Source

add asset metrics, split loggers (#1208)

* add asset metrics, split loggers

* add gitignored directory
Klaudiusz Dembler 3 years ago
parent
commit
6afc58e708
52 changed files with 382 additions and 195 deletions
  1. 1 0
      .env
  2. 0 1
      .gitignore
  3. 6 6
      src/api/client/resolvers.ts
  4. 2 2
      src/components/ChannelLink/ChannelLink.tsx
  5. 3 3
      src/components/Dialogs/ImageCropDialog/ImageCropDialog.tsx
  6. 2 2
      src/components/Dialogs/ImageCropDialog/cropper.ts
  7. 2 2
      src/components/InfiniteGrids/InfiniteChannelGrid.tsx
  8. 2 2
      src/components/InfiniteGrids/InfiniteVideoGrid.tsx
  9. 2 2
      src/components/InterruptedVideosGallery.tsx
  10. 2 2
      src/components/Sidenav/ViewerSidenav/ViewerSidenav.tsx
  11. 3 3
      src/components/VideoHero/VideoHeroData.ts
  12. 2 2
      src/components/VideoTile.tsx
  13. 2 2
      src/components/ViewErrorFallback.tsx
  14. 1 0
      src/config/urls.ts
  15. 6 8
      src/index.tsx
  16. 6 6
      src/joystream-lib/api.ts
  17. 9 9
      src/mocking/accessors/filtering.ts
  18. 2 2
      src/mocking/mutations.ts
  19. 3 3
      src/mocking/queries.ts
  20. 43 12
      src/providers/assets/assetsManager.tsx
  21. 49 8
      src/providers/assets/helpers.ts
  22. 2 2
      src/providers/editVideoSheet/hooks.tsx
  23. 2 2
      src/providers/joystream/provider.tsx
  24. 3 3
      src/providers/storageProviders.tsx
  25. 2 2
      src/providers/transactionManager/transactionManager.tsx
  26. 7 7
      src/providers/transactionManager/useTransaction.ts
  27. 5 5
      src/providers/uploadsManager/useStartFileUpload.tsx
  28. 6 6
      src/providers/user/user.tsx
  29. 5 5
      src/shared/components/VideoPlayer/VideoPlayer.tsx
  30. 4 4
      src/shared/components/VideoTileBase/VideoTile.stories.tsx
  31. 2 2
      src/store/index.ts
  32. 7 0
      src/types/assets.ts
  33. 2 2
      src/utils/localStorage.ts
  34. 70 0
      src/utils/logs/asset.ts
  35. 24 0
      src/utils/logs/console.ts
  36. 3 0
      src/utils/logs/index.ts
  37. 34 40
      src/utils/logs/sentry.ts
  38. 16 0
      src/utils/logs/shared.ts
  39. 3 1
      src/utils/misc.ts
  40. 2 2
      src/views/admin/AdminView.tsx
  41. 2 2
      src/views/playground/Playgrounds/PlaygroundConnectionState.tsx
  42. 2 2
      src/views/playground/Playgrounds/PlaygroundValidationForm.tsx
  43. 3 3
      src/views/studio/CreateEditChannelView/CreateEditChannelView.tsx
  44. 3 3
      src/views/studio/CreateMemberView/CreateMemberView.tsx
  45. 3 3
      src/views/studio/EditVideoSheet/EditVideoForm/EditVideoForm.tsx
  46. 4 4
      src/views/studio/EditVideoSheet/EditVideoSheet.tsx
  47. 2 2
      src/views/studio/MyVideosView/MyVideosView.tsx
  48. 6 6
      src/views/viewer/ChannelView/ChannelView.tsx
  49. 2 2
      src/views/viewer/HomeView.tsx
  50. 2 2
      src/views/viewer/SearchOverlayView/SearchResults/SearchResults.tsx
  51. 3 3
      src/views/viewer/VideoView/VideoView.tsx
  52. 3 3
      src/views/viewer/VideosView/VideosView.tsx

+ 1 - 0
.env

@@ -12,6 +12,7 @@ REACT_APP_DEVELOPMENT_FAUCET_URL=https://sumer-dev-2.joystream.app/members/regis
 REACT_APP_PRODUCTION_QUERY_NODE_URL=https://hydra.joystream.org/graphql
 REACT_APP_PRODUCTION_QUERY_NODE_SUBSCRIPTION_URL=wss://hydra.joystream.org/graphql
 REACT_APP_PRODUCTION_ORION_URL=https://orion.joystream.org/graphql
+REACT_APP_PRODUCTION_ASSET_LOGS_URL=https://orion.joystream.org/logs
 REACT_APP_PRODUCTION_NODE_URL=wss://rome-rpc-endpoint.joystream.org:9944/
 REACT_APP_PRODUCTION_FAUCET_URL=https://member-faucet.joystream.org/register
 

+ 0 - 1
.gitignore

@@ -1,5 +1,4 @@
 # Logs
-logs
 *.log
 npm-debug.log*
 yarn-debug.log*

+ 6 - 6
src/api/client/resolvers.ts

@@ -3,7 +3,7 @@ import type { IResolvers, ISchemaLevelResolver } from '@graphql-tools/utils'
 import { GraphQLSchema } from 'graphql'
 
 import { createLookup } from '@/utils/data'
-import { Logger } from '@/utils/logger'
+import { ConsoleLogger } from '@/utils/logs'
 
 import {
   ORION_BATCHED_FOLLOWS_QUERY_NAME,
@@ -84,7 +84,7 @@ export const queryNodeStitchingResolvers = (
         const viewsLookup = createLookup<{ id: string; views: number }>(batchedVideoViews || [])
         return videos.map((video: Video) => ({ ...video, views: viewsLookup[video.id]?.views || 0 }))
       } catch (error) {
-        Logger.warn('Failed to resolve videos field', { error })
+        ConsoleLogger.warn('Failed to resolve videos field', { error })
         return null
       }
     },
@@ -143,7 +143,7 @@ export const queryNodeStitchingResolvers = (
           views: viewsLookup[channel.id]?.views || 0,
         }))
       } catch (error) {
-        Logger.warn('Failed to resolve channels field', { error })
+        ConsoleLogger.warn('Failed to resolve channels field', { error })
         return null
       }
     },
@@ -179,7 +179,7 @@ export const queryNodeStitchingResolvers = (
           info
         )
       } catch (error) {
-        Logger.warn('Failed to resolve views field', { error })
+        ConsoleLogger.warn('Failed to resolve views field', { error })
         return null
       }
     },
@@ -235,7 +235,7 @@ export const queryNodeStitchingResolvers = (
           info
         )
       } catch (error) {
-        Logger.warn('Failed to resolve views field', { error })
+        ConsoleLogger.warn('Failed to resolve views field', { error })
         return null
       }
     },
@@ -259,7 +259,7 @@ export const queryNodeStitchingResolvers = (
           info
         )
       } catch (error) {
-        Logger.warn('Failed to resolve follows field', { error })
+        ConsoleLogger.warn('Failed to resolve follows field', { error })
         return null
       }
     },

+ 2 - 2
src/components/ChannelLink/ChannelLink.tsx

@@ -5,7 +5,7 @@ import { BasicChannelFieldsFragment } from '@/api/queries'
 import { absoluteRoutes } from '@/config/routes'
 import { AssetType, useAsset } from '@/providers'
 import { Avatar, AvatarSize } from '@/shared/components/Avatar'
-import { Logger } from '@/utils/logger'
+import { SentryLogger } from '@/utils/logs'
 
 import { Container, HandleSkeletonLoader, StyledText } from './ChannelLink.style'
 
@@ -36,7 +36,7 @@ export const ChannelLink: React.FC<ChannelLinkProps> = ({
   const { channel } = useBasicChannel(id || '', {
     skip: !id,
     onCompleted: (data) => !data && onNotFound?.(),
-    onError: (error) => Logger.captureError('Failed to fetch channel', 'ChannelLink', error, { channel: { id } }),
+    onError: (error) => SentryLogger.error('Failed to fetch channel', 'ChannelLink', error, { channel: { id } }),
   })
   const { url: avatarPhotoUrl } = useAsset({
     entity: channel,

+ 3 - 3
src/components/Dialogs/ImageCropDialog/ImageCropDialog.tsx

@@ -4,7 +4,7 @@ import { IconButton } from '@/shared/components'
 import { SvgGlyphPan, SvgGlyphZoomIn, SvgGlyphZoomOut } from '@/shared/icons'
 import { AssetDimensions, ImageCropData } from '@/types/cropper'
 import { validateImage } from '@/utils/image'
-import { Logger } from '@/utils/logger'
+import { SentryLogger } from '@/utils/logs'
 
 import {
   AlignInfo,
@@ -86,7 +86,7 @@ const ImageCropDialogComponent: React.ForwardRefRenderFunction<
   const handleFileChange = async () => {
     const files = inputRef.current?.files
     if (!files?.length) {
-      Logger.captureError('No files selected for image cropping', 'ImageCropDialog')
+      SentryLogger.error('No files selected for image cropping', 'ImageCropDialog')
       return
     }
     const selectedFile = files[0]
@@ -97,7 +97,7 @@ const ImageCropDialogComponent: React.ForwardRefRenderFunction<
       setShowDialog(true)
     } catch (error) {
       onError?.(error)
-      Logger.captureError('Failed to load image for image cropping', 'ImageCropDialog', error, {
+      SentryLogger.error('Failed to load image for image cropping', 'ImageCropDialog', error, {
         file: { name: selectedFile.name, type: selectedFile.type, size: selectedFile.size },
       })
     }

+ 2 - 2
src/components/Dialogs/ImageCropDialog/cropper.ts

@@ -3,7 +3,7 @@ import 'cropperjs/dist/cropper.min.css'
 import { useEffect, useState } from 'react'
 
 import { AssetDimensions, ImageCropData } from '@/types/cropper'
-import { Logger } from '@/utils/logger'
+import { SentryLogger } from '@/utils/logs'
 
 const MAX_ZOOM = 3
 
@@ -165,7 +165,7 @@ export const useCropper = ({ imageEl, imageType, cropData }: UseCropperOpts) =>
       }
       canvas.toBlob((blob) => {
         if (!blob) {
-          Logger.captureError('Got an empty blob from cropped canvas', 'ImageCropDialog')
+          SentryLogger.error('Got an empty blob from cropped canvas', 'ImageCropDialog')
           return
         }
         const url = URL.createObjectURL(blob)

+ 2 - 2
src/components/InfiniteGrids/InfiniteChannelGrid.tsx

@@ -9,7 +9,7 @@ import {
 } from '@/api/queries'
 import { Grid, Text } from '@/shared/components'
 import { sizes } from '@/shared/theme'
-import { Logger } from '@/utils/logger'
+import { SentryLogger } from '@/utils/logs'
 
 import { useInfiniteGrid } from './useInfiniteGrid'
 
@@ -57,7 +57,7 @@ export const InfiniteChannelGrid: React.FC<InfiniteChannelGridProps> = ({
     targetRowsCount,
     dataAccessor: (rawData) => rawData?.channelsConnection,
     itemsPerRow: channelsPerRow,
-    onError: (error) => Logger.captureError('Failed to fetch channels', 'InfiniteChannelsGrid', error),
+    onError: (error) => SentryLogger.error('Failed to fetch channels', 'InfiniteChannelsGrid', error),
   })
 
   const placeholderItems = Array.from({ length: placeholdersCount }, () => ({ id: undefined }))

+ 2 - 2
src/components/InfiniteGrids/InfiniteVideoGrid.tsx

@@ -10,7 +10,7 @@ import {
 } from '@/api/queries'
 import { Grid, SkeletonLoader, Text } from '@/shared/components'
 import { sizes } from '@/shared/theme'
-import { Logger } from '@/utils/logger'
+import { SentryLogger } from '@/utils/logs'
 
 import { useInfiniteGrid } from './useInfiniteGrid'
 
@@ -104,7 +104,7 @@ export const InfiniteVideoGrid: React.FC<InfiniteVideoGridProps> = ({
       return rawData?.videosConnection
     },
     itemsPerRow: videosPerRow,
-    onError: (error) => Logger.captureError('Failed to fetch videos', 'InfiniteVideoGrid', error),
+    onError: (error) => SentryLogger.error('Failed to fetch videos', 'InfiniteVideoGrid', error),
   })
 
   // handle category change

+ 2 - 2
src/components/InterruptedVideosGallery.tsx

@@ -3,7 +3,7 @@ import React from 'react'
 
 import { VideoGallery } from '@/components'
 import { usePersonalDataStore } from '@/providers'
-import { Logger } from '@/utils/logger'
+import { ConsoleLogger } from '@/utils/logs'
 
 const INTERRUPTED_VIDEOS_COUNT = 16
 
@@ -24,7 +24,7 @@ export const InterruptedVideosGallery: React.FC<RouteComponentProps> = () => {
   }
 
   const onVideoNotFound = (id: string) => {
-    Logger.warn(`Interrupted video not found, removing id: ${id}`)
+    ConsoleLogger.warn(`Interrupted video not found, removing id: ${id}`)
     updateWatchedVideos('REMOVED', id)
   }
 

+ 2 - 2
src/components/Sidenav/ViewerSidenav/ViewerSidenav.tsx

@@ -6,7 +6,7 @@ import { usePersonalDataStore } from '@/providers'
 import { Button } from '@/shared/components'
 import { SvgGlyphExternal, SvgNavChannels, SvgNavHome, SvgNavVideos } from '@/shared/icons'
 import { openInNewTab } from '@/utils/browser'
-import { Logger } from '@/utils/logger'
+import { ConsoleLogger } from '@/utils/logs'
 
 import { FollowedChannels } from './FollowedChannels'
 
@@ -34,7 +34,7 @@ export const ViewerSidenav: React.FC = () => {
   const updateChannelFollowing = usePersonalDataStore((state) => state.actions.updateChannelFollowing)
 
   const handleChannelNotFound = (id: string) => {
-    Logger.warn(`Followed channel not found, removing id: ${id}`)
+    ConsoleLogger.warn(`Followed channel not found, removing id: ${id}`)
     updateChannelFollowing(id, false)
   }
 

+ 3 - 3
src/components/VideoHero/VideoHeroData.ts

@@ -4,7 +4,7 @@ import { useEffect, useState } from 'react'
 import { useVideo } from '@/api/hooks/video'
 import { VideoFieldsFragment } from '@/api/queries'
 import { COVER_VIDEO_INFO_URL } from '@/config/urls'
-import { Logger } from '@/utils/logger'
+import { SentryLogger } from '@/utils/logs'
 
 import backupVideoHeroInfo from './backupVideoHeroInfo.json'
 
@@ -26,7 +26,7 @@ export const useVideoHero = (): CoverInfo => {
   const { video } = useVideo(fetchedCoverInfo?.videoId || '', {
     skip: !fetchedCoverInfo?.videoId,
     onError: (error) =>
-      Logger.captureError('Failed to fetch video hero', 'VideoHero', error, {
+      SentryLogger.error('Failed to fetch video hero', 'VideoHero', error, {
         video: { id: fetchedCoverInfo?.videoId },
       }),
   })
@@ -37,7 +37,7 @@ export const useVideoHero = (): CoverInfo => {
         const response = await axios.get<RawCoverInfo>(COVER_VIDEO_INFO_URL)
         setFetchedCoverInfo(response.data)
       } catch (e) {
-        Logger.captureError('Failed to fetch video hero info', 'VideoHero', e, {
+        SentryLogger.error('Failed to fetch video hero info', 'VideoHero', e, {
           videoHero: { url: COVER_VIDEO_INFO_URL },
         })
         setFetchedCoverInfo(backupVideoHeroInfo)

+ 2 - 2
src/components/VideoTile.tsx

@@ -11,7 +11,7 @@ import {
   VideoTilePublisherProps,
 } from '@/shared/components/VideoTileBase/VideoTileBase'
 import { copyToClipboard, openInNewTab } from '@/utils/browser'
-import { Logger } from '@/utils/logger'
+import { SentryLogger } from '@/utils/logs'
 
 export type VideoTileProps = {
   id?: string
@@ -97,7 +97,7 @@ const useVideoSharedLogic = ({ id, isDraft, onNotFound }: UseVideoSharedLogicOpt
   const { video, loading } = useVideo(id ?? '', {
     skip: !id || isDraft,
     onCompleted: (data) => !data && onNotFound?.(),
-    onError: (error) => Logger.captureError('Failed to fetch video', 'VideoTile', error, { video: { id } }),
+    onError: (error) => SentryLogger.error('Failed to fetch video', 'VideoTile', error, { video: { id } }),
   })
   const internalIsLoadingState = loading || !id
   const videoHref = id ? absoluteRoutes.viewer.video(id) : undefined

+ 2 - 2
src/components/ViewErrorFallback.tsx

@@ -7,11 +7,11 @@ import { absoluteRoutes } from '@/config/routes'
 import { JOYSTREAM_DISCORD_URL } from '@/config/urls'
 import { AnimatedError, Button, Text } from '@/shared/components'
 import { media, sizes } from '@/shared/theme'
-import { Logger } from '@/utils/logger'
+import { SentryLogger } from '@/utils/logs'
 
 // this isn't a react component, just a function that will be executed once to get a react element
 export const ViewErrorBoundary: FallbackRender = ({ error, resetError }) => {
-  Logger.captureError('Unhandled exception was thrown', 'ErrorBoundary', error)
+  SentryLogger.error('Unhandled exception was thrown', 'ErrorBoundary', error)
   return <ViewErrorFallback onResetClick={resetError} />
 }
 

+ 1 - 0
src/config/urls.ts

@@ -3,6 +3,7 @@ import { readEnv } from './envs'
 export const QUERY_NODE_GRAPHQL_URL = readEnv('QUERY_NODE_URL')
 export const QUERY_NODE_GRAPHQL_SUBSCRIPTION_URL = readEnv('QUERY_NODE_SUBSCRIPTION_URL')
 export const ORION_GRAPHQL_URL = readEnv('ORION_URL')
+export const ASSET_LOGS_URL = readEnv('ASSET_LOGS_URL', false)
 export const NODE_URL = readEnv('NODE_URL')
 export const FAUCET_URL = readEnv('FAUCET_URL')
 

+ 6 - 8
src/index.tsx

@@ -1,12 +1,11 @@
-import * as Sentry from '@sentry/react'
 import React from 'react'
 import ReactDOM from 'react-dom'
 
-import { Logger } from '@/utils/logger'
+import { AssetLogger, ConsoleLogger, SentryLogger } from '@/utils/logs'
 
 import { App } from './App'
 import { BUILD_ENV, TARGET_DEV_ENV } from './config/envs'
-import { SENTRY_DSN } from './config/urls'
+import { ASSET_LOGS_URL, SENTRY_DSN } from './config/urls'
 
 const initApp = async () => {
   if (BUILD_ENV !== 'production' && TARGET_DEV_ENV === 'mocking') {
@@ -14,16 +13,15 @@ const initApp = async () => {
       const { worker } = await import('./mocking/browser')
       await worker.start()
     } catch (e) {
-      Logger.error('Failed to load mocking server', e)
+      ConsoleLogger.error('Failed to load mocking server', e)
     }
   }
 
   if (BUILD_ENV === 'production') {
-    Sentry.init({
-      dsn: SENTRY_DSN,
-      ignoreErrors: ['ResizeObserver loop limit exceeded'],
-    })
+    SentryLogger.initialize(SENTRY_DSN)
+    AssetLogger.initialize(ASSET_LOGS_URL)
   }
+
   ReactDOM.render(<App />, document.getElementById('root'))
 }
 

+ 6 - 6
src/joystream-lib/api.ts

@@ -33,7 +33,7 @@ import {
 import { DispatchError } from '@polkadot/types/interfaces/system'
 import BN from 'bn.js'
 
-import { Logger } from '@/utils/logger'
+import { ConsoleLogger, SentryLogger } from '@/utils/logs'
 
 import {
   AccountNotSelectedError,
@@ -87,14 +87,14 @@ export class JoystreamJs {
 
   destroy() {
     this.api.disconnect()
-    Logger.log('[JoystreamJs] Destroyed')
+    ConsoleLogger.log('[JoystreamJs] Destroyed')
   }
 
   private async ensureApi() {
     try {
       await this.api.isReady
     } catch (e) {
-      Logger.captureError('Failed to initialize Polkadot API', 'JoystreamJs', e)
+      SentryLogger.error('Failed to initialize Polkadot API', 'JoystreamJs', e)
       throw new ApiNotConnectedError()
     }
   }
@@ -102,7 +102,7 @@ export class JoystreamJs {
   private async logConnectionData(endpoint: string) {
     await this.ensureApi()
     const chain = await this.api.rpc.system.chain()
-    Logger.log(`[JoystreamJs] Connected to chain "${chain}" via "${endpoint}"`)
+    ConsoleLogger.log(`[JoystreamJs] Connected to chain "${chain}" via "${endpoint}"`)
   }
 
   private async sendExtrinsic(
@@ -159,7 +159,7 @@ export class JoystreamJs {
                     .then(({ number }) => resolve({ block: number.toNumber(), data: unpackedEvents }))
                     .catch((reason) => reject(new ExtrinsicFailedError(reason)))
                 } else {
-                  Logger.captureMessage('Unknown extrinsic event', 'JoystreamJs', 'warning', {
+                  SentryLogger.message('Unknown extrinsic event', 'JoystreamJs', 'warning', {
                     event: { method: event.method },
                   })
                 }
@@ -396,7 +396,7 @@ export class JoystreamJs {
       this.api.setSigner({})
       return
     } else if (!signer) {
-      Logger.captureError('Missing signer for setActiveAccount', 'JoystreamJs')
+      SentryLogger.error('Missing signer for setActiveAccount', 'JoystreamJs')
       return
     }
 

+ 9 - 9
src/mocking/accessors/filtering.ts

@@ -1,6 +1,6 @@
 import { get } from 'lodash'
 
-import { Logger } from '@/utils/logger'
+import { ConsoleLogger } from '@/utils/logs'
 
 import { FilteringArgs, GenericData, PredicateFn, SortingArgs } from '../types'
 
@@ -32,7 +32,7 @@ const createPredicate = (key: string, value: any, testData: GenericData): Predic
     const accessKey = field.endsWith('Id') ? `${field.replace('Id', '')}.id` : field
     const testValue = get(testData, accessKey, undefined)
     if (testValue === undefined) {
-      Logger.warn(`skipping filtering by unknown field "${accessKey}"`)
+      ConsoleLogger.warn(`skipping filtering by unknown field "${accessKey}"`)
       return () => true
     }
 
@@ -45,7 +45,7 @@ const createPredicate = (key: string, value: any, testData: GenericData): Predic
     const accessKey = field.endsWith('Id') ? `${field.replace('Id', '')}.id` : field
     const testValue = get(testData, accessKey, undefined)
     if (testValue === undefined) {
-      Logger.warn(`skipping filtering by unknown field "${accessKey}"`)
+      ConsoleLogger.warn(`skipping filtering by unknown field "${accessKey}"`)
       return () => true
     }
 
@@ -58,11 +58,11 @@ const createPredicate = (key: string, value: any, testData: GenericData): Predic
     const accessKey = field.endsWith('Id') ? `${field.replace('Id', '')}.id` : field
     const testValue = get(testData, accessKey, undefined)
     if (testValue === undefined) {
-      Logger.warn(`skipping filtering by unknown field "${accessKey}"`)
+      ConsoleLogger.warn(`skipping filtering by unknown field "${accessKey}"`)
       return () => true
     }
     if (accessKey !== 'createdAt') {
-      Logger.warn(`skipping filtering by unsupported "_gte" field: "${key}"`)
+      ConsoleLogger.warn(`skipping filtering by unsupported "_gte" field: "${key}"`)
       return () => true
     }
 
@@ -73,7 +73,7 @@ const createPredicate = (key: string, value: any, testData: GenericData): Predic
       return isoCreatedAt >= value
     }
   } else {
-    Logger.warn(`skipping filtering by arbitrary filter "${key}"`)
+    ConsoleLogger.warn(`skipping filtering by arbitrary filter "${key}"`)
     return () => true
   }
 }
@@ -86,7 +86,7 @@ export const genericSort = <TData extends GenericData>(data: TData[], variables:
 
   const [field, direction] = orderBy[0].split('_')
   if (!field || !direction) {
-    Logger.warn(`error parsing orderBy: "${orderBy}"`)
+    ConsoleLogger.warn(`error parsing orderBy: "${orderBy}"`)
     return data
   }
 
@@ -103,11 +103,11 @@ export const genericSort = <TData extends GenericData>(data: TData[], variables:
     } else if (direction === 'ASC') {
       return sortedData.reverse()
     } else {
-      Logger.warn(`unknown sort direction: "${direction}"`)
+      ConsoleLogger.warn(`unknown sort direction: "${direction}"`)
       return sortedData
     }
   } else {
-    Logger.warn(`unsupported sorting field: "${field}"`)
+    ConsoleLogger.warn(`unsupported sorting field: "${field}"`)
     return data
   }
 }

+ 2 - 2
src/mocking/mutations.ts

@@ -11,7 +11,7 @@ import {
   UnfollowChannelMutation,
   UnfollowChannelMutationVariables,
 } from '@/api/queries'
-import { Logger } from '@/utils/logger'
+import { ConsoleLogger } from '@/utils/logs'
 
 import { BaseDataQuery, DataMutator, Link, MocksStore } from './types'
 import { normalizeVariables, parseOperationDocument } from './utils'
@@ -23,7 +23,7 @@ const createGenericMutationHandler = <TQuery extends BaseDataQuery, TVariables =
 ) => {
   const { operationName } = parseOperationDocument(mutationDocument)
   if (!operationName) {
-    Logger.error('Unable to resolve operation name for mocking', mutationDocument)
+    ConsoleLogger.error('Unable to resolve operation name for mocking', mutationDocument)
     return
   }
 

+ 3 - 3
src/mocking/queries.ts

@@ -1,6 +1,6 @@
 import { DocumentNode } from 'graphql'
 
-import { Logger } from '@/utils/logger'
+import { ConsoleLogger } from '@/utils/logs'
 
 import { BaseDataQuery, DataAccessor, Link } from './types'
 import { normalizeVariables, parseOperationDocument } from './utils'
@@ -13,12 +13,12 @@ export const createQueryHandler = <TQuery extends BaseDataQuery, TVariables = un
   const { operationName, primaryOperationFieldName } = parseOperationDocument(queryDocument)
 
   if (!operationName) {
-    Logger.error('Unable to resolve operation name for mocking', queryDocument)
+    ConsoleLogger.error('Unable to resolve operation name for mocking', queryDocument)
     return
   }
 
   if (!primaryOperationFieldName) {
-    Logger.error('Unable to resolve primary operation field for mocking', queryDocument)
+    ConsoleLogger.error('Unable to resolve primary operation field for mocking', queryDocument)
     return
   }
 

+ 43 - 12
src/providers/assets/assetsManager.tsx

@@ -1,7 +1,11 @@
 import { shuffle } from 'lodash'
 import React, { useEffect } from 'react'
 
-import { Logger } from '@/utils/logger'
+import { ASSET_RESPONSE_TIMEOUT } from '@/config/assets'
+import { AssetType } from '@/providers'
+import { ResolvedAssetDetails } from '@/types/assets'
+import { AssetLogger, ConsoleLogger, SentryLogger } from '@/utils/logs'
+import { TimeoutError, withTimeout } from '@/utils/misc'
 
 import { getAssetUrl, testAssetDownload } from './helpers'
 import { useAssetStore } from './store'
@@ -36,29 +40,56 @@ export const AssetsManager: React.FC = () => {
       for (const storageProvider of storageProvidersToTry) {
         const assetUrl = getAssetUrl(resolutionData, storageProvider.metadata ?? '')
         if (!assetUrl) {
-          Logger.warn('Unable to create asset url', resolutionData)
+          ConsoleLogger.warn('Unable to create asset url', resolutionData)
           addAsset(contentId, {})
           return
         }
 
+        const assetTestPromise = testAssetDownload(assetUrl, resolutionData.assetType)
+
+        const assetDetails: ResolvedAssetDetails = {
+          contentId,
+          storageProviderId: storageProvider.workerId,
+          storageProviderUrl: storageProvider.metadata,
+          assetType: resolutionData.assetType,
+          assetUrl,
+        }
+
+        assetTestPromise.then((responseTime) => {
+          if (resolutionData.assetType === AssetType.MEDIA) {
+            // we're currently skipping monitoring video files as it's hard to measure their performance
+            // image assets are easy to measure but videos vary in length and size
+            // we will be able to handle that once we can access more detailed response timing
+            return
+          }
+
+          // if response takes <20ms assume it's coming from cache
+          // we shouldn't need that once we can do detailed timing, then we can check directly
+          if (responseTime > 20) {
+            AssetLogger.assetResponseMetric(assetDetails, responseTime)
+          }
+        })
+        assetTestPromise.catch(() => {
+          AssetLogger.assetError(assetDetails)
+          ConsoleLogger.error('Failed to load asset', assetDetails)
+        })
+        const assetTestPromiseWithTimeout = withTimeout(assetTestPromise, ASSET_RESPONSE_TIMEOUT)
+
         try {
-          await testAssetDownload(assetUrl, resolutionData.assetType)
+          await assetTestPromiseWithTimeout
           addAsset(contentId, { url: assetUrl })
           removePendingAsset(contentId)
           removeAssetBeingResolved(contentId)
           return
         } catch (e) {
-          // don't capture every single asset timeout as error, just log it
-          Logger.error('Failed to load asset', {
-            contentId,
-            type: resolutionData.assetType,
-            storageProviderId: storageProvider.workerId,
-            storageProviderUrl: storageProvider.metadata,
-            assetUrl,
-          })
+          // ignore anything else than TimeoutError as it will be handled by assetTestPromise.catch
+          if (e instanceof TimeoutError) {
+            ConsoleLogger.warn('Asset load timed out', assetDetails)
+          }
         }
       }
-      Logger.captureError('No storage provider was able to provide asset', 'AssetsManager', null, {
+
+      SentryLogger.error('No storage provider was able to provide asset', 'AssetsManager', null, {
         asset: {
           contentId,
           type: resolutionData.assetType,

+ 49 - 8
src/providers/assets/helpers.ts

@@ -5,27 +5,68 @@ import {
   BasicVideoFieldsFragment,
   VideoFieldsFragment,
 } from '@/api/queries'
-import { ASSET_RESPONSE_TIMEOUT } from '@/config/assets'
 import { createStorageNodeUrl } from '@/utils/asset'
-import { withTimeout } from '@/utils/misc'
+import { ConsoleLogger } from '@/utils/logs'
 
 import { AssetResolutionData, AssetType } from './types'
 
-export const testAssetDownload = (url: string, type: AssetType) => {
-  const testPromise = new Promise((resolve, reject) => {
+export const testAssetDownload = (url: string, type: AssetType): Promise<number> => {
+  return new Promise((_resolve, _reject) => {
+    let img: HTMLImageElement | null = null
+    let video: HTMLVideoElement | null = null
+
+    const cleanup = () => {
+      if (img) {
+        img.removeEventListener('error', reject)
+        img.removeEventListener('load', resolve)
+        img.remove()
+        img = null
+      }
+      if (video) {
+        video.removeEventListener('error', reject)
+        video.removeEventListener('loadedmetadata', resolve)
+        video.removeEventListener('loadeddata', resolve)
+        video.removeEventListener('canplay', resolve)
+        video.removeEventListener('progress', resolve)
+        video.remove()
+        video = null
+      }
+    }
+
+    const resolve = () => {
+      cleanup()
+
+      const performanceEntries = performance.getEntriesByName(url)
+      if (performanceEntries.length !== 1) {
+        if (type !== AssetType.MEDIA) {
+          ConsoleLogger.warn('Unexpected number of performance timing entries', { url, performanceEntries })
+        }
+        _resolve(0)
+        return
+      }
+      _resolve(performanceEntries[0].duration)
+    }
+
+    const reject = () => {
+      cleanup()
+      _reject()
+    }
+
     if ([AssetType.COVER, AssetType.THUMBNAIL, AssetType.AVATAR].includes(type)) {
-      const img = new Image()
+      img = new Image()
       img.addEventListener('error', reject)
       img.addEventListener('load', resolve)
       img.src = url
     } else {
-      const video = document.createElement('video')
+      video = document.createElement('video')
       video.addEventListener('error', reject)
-      video.addEventListener('loadstart', resolve)
+      video.addEventListener('loadedmetadata', resolve)
+      video.addEventListener('loadeddata', resolve)
+      video.addEventListener('canplay', resolve)
+      video.addEventListener('progress', resolve)
       video.src = url
     }
   })
-  return withTimeout(testPromise, ASSET_RESPONSE_TIMEOUT)
 }
 export const readAssetData = (
   entity:

+ 2 - 2
src/providers/editVideoSheet/hooks.tsx

@@ -7,7 +7,7 @@ import { useNavigate } from 'react-router-dom'
 import { useVideo } from '@/api/hooks'
 import { absoluteRoutes } from '@/config/routes'
 import { RoutingState } from '@/types/routing'
-import { Logger } from '@/utils/logger'
+import { SentryLogger } from '@/utils/logs'
 
 import { EditVideoSheetContext } from './provider'
 import { EditVideoAssets, EditVideoFormFields, EditVideoSheetState, EditVideoSheetTab } from './types'
@@ -28,7 +28,7 @@ export const useEditVideoSheetTabData = (tab?: EditVideoSheetTab) => {
   const { selectedVideoTabCachedAssets } = useEditVideoSheet()
   const { video, loading, error } = useVideo(tab?.id ?? '', {
     skip: tab?.isDraft,
-    onError: (error) => Logger.captureError('Failed to fetch video', 'useEditVideoSheetTabData', error),
+    onError: (error) => SentryLogger.error('Failed to fetch video', 'useEditVideoSheetTabData', error),
   })
 
   if (!tab) {

+ 2 - 2
src/providers/joystream/provider.tsx

@@ -3,7 +3,7 @@ import React, { useCallback, useEffect, useState } from 'react'
 
 import { NODE_URL } from '@/config/urls'
 import { JoystreamJs } from '@/joystream-lib'
-import { Logger } from '@/utils/logger'
+import { SentryLogger } from '@/utils/logs'
 
 import { useConnectionStatusStore, useUser } from '..'
 
@@ -38,7 +38,7 @@ export const JoystreamProvider: React.FC = ({ children }) => {
         joystream.onNodeConnectionUpdate = handleNodeConnectionUpdate
       } catch (e) {
         handleNodeConnectionUpdate(false)
-        Logger.captureError('Failed to create JoystreamJS instance', 'JoystreamProvider', e)
+        SentryLogger.error('Failed to create JoystreamJS instance', 'JoystreamProvider', e)
       }
     }
 

+ 3 - 3
src/providers/storageProviders.tsx

@@ -10,7 +10,7 @@ import {
   GetWorkersQueryVariables,
 } from '@/api/queries/__generated__/workers.generated'
 import { ViewErrorFallback } from '@/components'
-import { Logger } from '@/utils/logger'
+import { SentryLogger } from '@/utils/logs'
 import { getRandomIntInclusive } from '@/utils/number'
 
 type StorageProvidersPromise = Promise<ApolloQueryResult<GetWorkersQuery>>
@@ -42,7 +42,7 @@ export const StorageProvidersProvider: React.FC = ({ children }) => {
     })
     storageProvidersPromiseRef.current = promise
     promise.catch((error) => {
-      Logger.captureError('Failed to fetch storage providers list', 'StorageProvidersProvider', error)
+      SentryLogger.error('Failed to fetch storage providers list', 'StorageProvidersProvider', error)
       setStorageProvidersError(error)
     })
   }, [client])
@@ -88,7 +88,7 @@ export const useStorageProviders = () => {
     )
 
     if (!workingStorageProviders.length) {
-      Logger.captureError('No storage provider available', 'StorageProvidersProvider', null, {
+      SentryLogger.error('No storage provider available', 'StorageProvidersProvider', null, {
         providers: {
           allIds: storageProviders.map(({ workerId }) => workerId),
           notWorkingIds: notWorkingStorageProvidersIds,

+ 2 - 2
src/providers/transactionManager/transactionManager.tsx

@@ -2,7 +2,7 @@ import React from 'react'
 
 import { useQueryNodeStateSubscription } from '@/api/hooks'
 import { TransactionDialog } from '@/components'
-import { Logger } from '@/utils/logger'
+import { SentryLogger } from '@/utils/logs'
 
 import { useTransactionManagerStore } from './store'
 
@@ -24,7 +24,7 @@ export const TransactionManager: React.FC = () => {
         try {
           action.callback()
         } catch (e) {
-          Logger.captureError('Failed to execute tx sync callback', 'TransactionManager', e)
+          SentryLogger.error('Failed to execute tx sync callback', 'TransactionManager', e)
         }
       })
 

+ 7 - 7
src/providers/transactionManager/useTransaction.ts

@@ -1,6 +1,6 @@
 import { ExtrinsicFailedError, ExtrinsicResult, ExtrinsicSignCancelledError, ExtrinsicStatus } from '@/joystream-lib'
 import { TransactionDialogStep, useConnectionStatusStore, useDialog, useSnackbar } from '@/providers'
-import { Logger } from '@/utils/logger'
+import { ConsoleLogger, SentryLogger } from '@/utils/logs'
 
 import { useTransactionManagerStore } from './store'
 
@@ -56,7 +56,7 @@ export const useTransaction = (): HandleTransactionFn => {
         try {
           await preProcess()
         } catch (e) {
-          Logger.captureError('Failed transaction preprocess', 'TransactionManager', e)
+          SentryLogger.error('Failed transaction preprocess', 'TransactionManager', e)
           return false
         }
       }
@@ -66,7 +66,7 @@ export const useTransaction = (): HandleTransactionFn => {
       const { data: txData, block } = await txFactory(setDialogStep)
       if (onTxFinalize) {
         onTxFinalize(txData).catch((e) =>
-          Logger.captureError('Failed transaction finalize callback', 'TransactionManager', e)
+          SentryLogger.error('Failed transaction finalize callback', 'TransactionManager', e)
         )
       }
 
@@ -77,7 +77,7 @@ export const useTransaction = (): HandleTransactionFn => {
             try {
               await onTxSync(txData)
             } catch (e) {
-              Logger.captureError('Failed transaction sync callback', 'TransactionManager', e)
+              SentryLogger.error('Failed transaction sync callback', 'TransactionManager', e)
             }
           }
           resolve()
@@ -107,7 +107,7 @@ export const useTransaction = (): HandleTransactionFn => {
       })
     } catch (e) {
       if (e instanceof ExtrinsicSignCancelledError) {
-        Logger.warn('Sign cancelled')
+        ConsoleLogger.warn('Sign cancelled')
         setDialogStep(null)
         displaySnackbar({
           title: 'Transaction signing cancelled',
@@ -118,9 +118,9 @@ export const useTransaction = (): HandleTransactionFn => {
       }
 
       if (e instanceof ExtrinsicFailedError) {
-        Logger.captureError('Extrinsic failed', 'TransactionManager', e)
+        SentryLogger.error('Extrinsic failed', 'TransactionManager', e)
       } else {
-        Logger.captureError('Unknown sendExtrinsic error', 'TransactionManager', e)
+        SentryLogger.error('Unknown sendExtrinsic error', 'TransactionManager', e)
       }
       setDialogStep(null)
       openErrorDialog()

+ 5 - 5
src/providers/uploadsManager/useStartFileUpload.tsx

@@ -6,7 +6,7 @@ import * as rax from 'retry-axios'
 
 import { absoluteRoutes } from '@/config/routes'
 import { createStorageNodeUrl } from '@/utils/asset'
-import { Logger } from '@/utils/logger'
+import { ConsoleLogger, SentryLogger } from '@/utils/logs'
 
 import { useUploadsStore } from './store'
 import { InputAssetUpload, StartFileUploadOptions, UploadStatus } from './types'
@@ -84,17 +84,17 @@ export const useStartFileUpload = () => {
       try {
         const storageProvider = await getRandomStorageProvider()
         if (!storageProvider) {
-          Logger.captureError('No storage provider available for upload', 'UploadsManager')
+          SentryLogger.error('No storage provider available for upload', 'UploadsManager')
           return
         }
         storageUrl = storageProvider.url
         storageProviderId = storageProvider.id
       } catch (e) {
-        Logger.captureError('Failed to get storage provider for upload', 'UploadsManager', e)
+        SentryLogger.error('Failed to get storage provider for upload', 'UploadsManager', e)
         return
       }
 
-      Logger.debug('Starting file upload', {
+      ConsoleLogger.debug('Starting file upload', {
         contentId: asset.contentId,
         storageProviderId,
         storageProviderUrl: storageUrl,
@@ -167,7 +167,7 @@ export const useStartFileUpload = () => {
           (assetsNotificationsCount.current.uploaded[assetKey] || 0) + 1
         displayUploadedNotification.current(assetKey)
       } catch (e) {
-        Logger.captureError('Failed to upload asset', 'UploadsManager', e, {
+        SentryLogger.error('Failed to upload asset', 'UploadsManager', e, {
           asset: { contentId: asset.contentId, storageProviderId, storageProviderUrl: storageUrl, assetUrl },
         })
         setAssetStatus({ lastStatus: 'error', progress: 0 })

+ 6 - 6
src/providers/user/user.tsx

@@ -6,7 +6,7 @@ import { useMembership, useMemberships } from '@/api/hooks'
 import { ViewErrorFallback } from '@/components'
 import { WEB3_APP_NAME } from '@/config/urls'
 import { AccountId } from '@/joystream-lib'
-import { Logger } from '@/utils/logger'
+import { ConsoleLogger, SentryLogger } from '@/utils/logs'
 
 import { ActiveUserState, ActiveUserStoreActions, useActiveUserStore } from './store'
 
@@ -53,7 +53,7 @@ export const ActiveUserProvider: React.FC = ({ children }) => {
     {
       skip: !accounts || !accounts.length,
       onError: (error) =>
-        Logger.captureError('Failed to fetch memberships', 'ActiveUserProvider', error, {
+        SentryLogger.error('Failed to fetch memberships', 'ActiveUserProvider', error, {
           accounts: { ids: accountsIds },
         }),
     }
@@ -71,7 +71,7 @@ export const ActiveUserProvider: React.FC = ({ children }) => {
     { where: { id: activeUserState.memberId } },
     {
       skip: !activeUserState.memberId,
-      onError: (error) => Logger.captureError('Failed to fetch active membership', 'ActiveUserProvider', error),
+      onError: (error) => SentryLogger.error('Failed to fetch active membership', 'ActiveUserProvider', error),
     }
   )
 
@@ -84,7 +84,7 @@ export const ActiveUserProvider: React.FC = ({ children }) => {
         const enabledExtensions = await web3Enable(WEB3_APP_NAME)
 
         if (!enabledExtensions.length) {
-          Logger.warn('No Polkadot extension detected')
+          ConsoleLogger.warn('No Polkadot extension detected')
           setExtensionConnected(false)
           return
         }
@@ -105,7 +105,7 @@ export const ActiveUserProvider: React.FC = ({ children }) => {
         setExtensionConnected(true)
       } catch (e) {
         setExtensionConnected(false)
-        Logger.captureError('Failed to initialize Polkadot signer extension', 'ActiveUserProvider', e)
+        SentryLogger.error('Failed to initialize Polkadot signer extension', 'ActiveUserProvider', e)
       }
     }
 
@@ -124,7 +124,7 @@ export const ActiveUserProvider: React.FC = ({ children }) => {
     const account = accounts.find((a) => a.id === activeUserState.accountId)
 
     if (!account) {
-      Logger.warn('Selected accountId not found in extension accounts, resetting user')
+      ConsoleLogger.warn('Selected accountId not found in extension accounts, resetting user')
       resetActiveUser()
     }
   }, [accounts, activeUserState.accountId, extensionConnected, resetActiveUser])

+ 5 - 5
src/shared/components/VideoPlayer/VideoPlayer.tsx

@@ -14,7 +14,7 @@ import {
   SvgPlayerSoundHalf,
   SvgPlayerSoundOn,
 } from '@/shared/icons'
-import { Logger } from '@/utils/logger'
+import { ConsoleLogger, SentryLogger } from '@/utils/logs'
 import { formatDurationShort } from '@/utils/time'
 
 import { ControlsIndicator } from './ControlsIndicator'
@@ -130,9 +130,9 @@ const VideoPlayerComponent: React.ForwardRefRenderFunction<HTMLVideoElement, Vid
     if (playPromise) {
       playPromise.catch((e) => {
         if (e.name === 'NotAllowedError') {
-          Logger.warn('Video playback failed', e)
+          ConsoleLogger.warn('Video playback failed', e)
         } else {
-          Logger.captureError('Video playback failed', 'VideoPlayer', e, {
+          SentryLogger.error('Video playback failed', 'VideoPlayer', e, {
             video: { id: videoId, url: videoJsConfig.src },
           })
         }
@@ -196,7 +196,7 @@ const VideoPlayerComponent: React.ForwardRefRenderFunction<HTMLVideoElement, Vid
     const playPromise = player.play()
     if (playPromise) {
       playPromise.catch((e) => {
-        Logger.warn('Video autoplay failed', e)
+        ConsoleLogger.warn('Video autoplay failed', e)
       })
     }
   }, [player, isLoaded, autoplay])
@@ -400,7 +400,7 @@ const VideoPlayerComponent: React.ForwardRefRenderFunction<HTMLVideoElement, Vid
       if (document.pictureInPictureEnabled) {
         // @ts-ignore @types/video.js is outdated and doesn't provide types for some newer video.js features
         player.requestPictureInPicture().catch((e) => {
-          Logger.warn('Picture in picture failed', e)
+          ConsoleLogger.warn('Picture in picture failed', e)
         })
       }
     }

+ 4 - 4
src/shared/components/VideoTileBase/VideoTile.stories.tsx

@@ -4,7 +4,7 @@ import React from 'react'
 import { BrowserRouter } from 'react-router-dom'
 
 import { OverlayManagerProvider } from '@/providers'
-import { Logger } from '@/utils/logger'
+import { ConsoleLogger } from '@/utils/logs'
 
 import { VideoTileBase, VideoTileBaseProps } from './VideoTileBase'
 
@@ -46,7 +46,7 @@ const Publisher: Story<VideoTileBaseProps> = ({ createdAt, ...args }) => {
   const createdAtDate = new Date(createdAt ?? '')
 
   const handler = () => {
-    Logger.log('called')
+    ConsoleLogger.log('called')
   }
   return (
     <BrowserRouter>
@@ -135,7 +135,7 @@ PublisherUnlisted.args = {
 const Mix: Story<VideoTileBaseProps> = ({ createdAt, ...args }) => {
   const createdAtDate = new Date(createdAt ?? '')
   const handler = () => {
-    Logger.log('called')
+    ConsoleLogger.log('called')
   }
   return (
     <BrowserRouter>
@@ -188,7 +188,7 @@ Mixed.args = {
   publisherMode: true,
   thumbnailUrl: 'https://eu-central-1.linodeobjects.com/atlas-assets/cover-video/thumbnail.jpg',
   onClick: () => {
-    Logger.log('Click')
+    ConsoleLogger.log('Click')
   },
 }
 

+ 2 - 2
src/store/index.ts

@@ -2,7 +2,7 @@ import { Draft, enableMapSet, produce } from 'immer'
 import create, { GetState, State, StateCreator, StoreApi } from 'zustand'
 import { persist } from 'zustand/middleware'
 
-import { Logger } from '@/utils/logger'
+import { SentryLogger } from '@/utils/logs'
 
 export type CommonStore<TState, TActions> = {
   actions: TActions
@@ -61,7 +61,7 @@ export const createStore = <TState extends object, TActions extends object>(
         try {
           return config.migrate(oldState, oldVersion, storageValue) as CommonStore<TState, TActions>
         } catch (e) {
-          Logger.captureError(`Failed to migrate store "${config.key}"`, 'createStore', e)
+          SentryLogger.error(`Failed to migrate store "${config.key}"`, 'createStore', e)
           return {} as CommonStore<TState, TActions>
         }
       },

+ 7 - 0
src/types/assets.ts

@@ -0,0 +1,7 @@
+export type ResolvedAssetDetails = {
+  contentId: string
+  assetType: string
+  storageProviderId: string
+  storageProviderUrl?: string | null
+  assetUrl: string
+}

+ 2 - 2
src/utils/localStorage.ts

@@ -1,4 +1,4 @@
-import { Logger } from '@/utils/logger'
+import { SentryLogger } from '@/utils/logs'
 
 export const readFromLocalStorage = <T>(key: string, { deserialize = JSON.parse } = {}) => {
   const valueInLocalStorage = window.localStorage.getItem(key)
@@ -6,7 +6,7 @@ export const readFromLocalStorage = <T>(key: string, { deserialize = JSON.parse
     try {
       return deserialize(valueInLocalStorage) as T
     } catch (error) {
-      Logger.captureError('Failed to deserialize value from localStorage', 'readFromLocalStorage', error, {
+      SentryLogger.error('Failed to deserialize value from localStorage', 'readFromLocalStorage', error, {
         localStorage: { key, value: valueInLocalStorage },
       })
       throw error

+ 70 - 0
src/utils/logs/asset.ts

@@ -0,0 +1,70 @@
+import axios from 'axios'
+import { debounce } from 'lodash'
+
+import { ResolvedAssetDetails } from '@/types/assets'
+
+import { ConsoleLogger } from './console'
+import { SentryLogger } from './sentry'
+import { getUserInfo } from './shared'
+
+export type AssetEvent = {
+  type: string
+  storageProviderId: string
+  storageProviderUrl?: string | null
+} & Record<string, unknown>
+
+class _AssetLogger {
+  private logUrl = ''
+
+  initialize(logUrl: string | null) {
+    if (logUrl) this.logUrl = logUrl
+  }
+
+  private pendingEvents: AssetEvent[] = []
+
+  private sendEvents = debounce(async () => {
+    if (!this.pendingEvents.length) return
+    if (!this.logUrl) return
+
+    ConsoleLogger.debug(`Sending ${this.pendingEvents.length} asset events`)
+
+    const payload = {
+      events: this.pendingEvents,
+    }
+    this.pendingEvents = []
+
+    try {
+      await axios.post(this.logUrl, payload)
+    } catch (e) {
+      SentryLogger.error('Failed to send asset events', 'AssetLogger', e, { request: { url: this.logUrl } })
+    }
+  }, 2000)
+
+  private addEvent(event: AssetEvent) {
+    const eventWithUser = {
+      ...event,
+      user: getUserInfo(false),
+    }
+    this.pendingEvents.push(eventWithUser)
+    this.sendEvents()
+  }
+
+  assetResponseMetric(assetDetails: ResolvedAssetDetails, responseTime: number) {
+    const event: AssetEvent = {
+      type: 'asset-download-response-time',
+      responseTime,
+      ...assetDetails,
+    }
+    this.addEvent(event)
+  }
+
+  assetError(assetDetails: ResolvedAssetDetails) {
+    const event: AssetEvent = {
+      type: 'asset-download-failure',
+      ...assetDetails,
+    }
+    this.addEvent(event)
+  }
+}
+
+export const AssetLogger = new _AssetLogger()

+ 24 - 0
src/utils/logs/console.ts

@@ -0,0 +1,24 @@
+/* eslint-disable no-console */
+import { getLogArgs } from './shared'
+
+type LogFn = (message: string, details?: unknown) => void
+
+class _ConsoleLogger {
+  log: LogFn = (message, details) => {
+    console.log(...getLogArgs(message, details))
+  }
+
+  warn: LogFn = (message, details) => {
+    console.warn(...getLogArgs(message, details))
+  }
+
+  error: LogFn = (message, details) => {
+    console.error(...getLogArgs(message, details))
+  }
+
+  debug: LogFn = (message, details) => {
+    console.debug(...getLogArgs(message, details))
+  }
+}
+
+export const ConsoleLogger = new _ConsoleLogger()

+ 3 - 0
src/utils/logs/index.ts

@@ -0,0 +1,3 @@
+export * from './asset'
+export * from './console'
+export * from './sentry'

+ 34 - 40
src/utils/logger.ts → src/utils/logs/sentry.ts

@@ -1,10 +1,14 @@
-/* eslint-disable no-console */
 import * as Sentry from '@sentry/react'
 import { Severity } from '@sentry/react'
 
-import { useActiveUserStore } from '@/providers/user/store'
+import { ConsoleLogger } from './console'
+import { getUserInfo } from './shared'
 
-class CustomError extends Error {
+type LogContexts = Record<string, Record<string, unknown>>
+
+type LogMessageLevel = 'log' | 'warning' | 'error'
+
+class SentryError extends Error {
   name: string
   message: string
 
@@ -15,41 +19,24 @@ class CustomError extends Error {
   }
 }
 
-type LogContexts = Record<string, Record<string, unknown>>
-type LogFn = (message: string, details?: unknown) => void
-type LogMessageLevel = 'info' | 'warning' | 'error'
-
-const getLogArgs = (message: string, details?: unknown) => {
-  if (details) {
-    return [message, details]
-  }
-  return [message]
-}
-
-export class Logger {
-  static log: LogFn = (message, details) => {
-    console.log(...getLogArgs(message, details))
-  }
-
-  static warn: LogFn = (message, details) => {
-    console.warn(...getLogArgs(message, details))
-  }
+class _SentryLogger {
+  private initialized = false
 
-  static error: LogFn = (message, details) => {
-    console.error(...getLogArgs(message, details))
-  }
-
-  static debug: LogFn = (message, details) => {
-    console.debug(...getLogArgs(message, details))
+  initialize(DSN: string) {
+    Sentry.init({
+      dsn: DSN,
+      ignoreErrors: ['ResizeObserver loop limit exceeded'],
+    })
+    this.initialized = true
   }
 
-  static captureError = (
+  error(
     title: string,
     source: string,
     // eslint-disable-next-line @typescript-eslint/no-explicit-any
     rawError?: any,
     contexts?: LogContexts
-  ) => {
+  ) {
     let error = rawError
     const tags: Record<string, string | number> = {
       source,
@@ -74,9 +61,14 @@ export class Logger {
 
     const message = rawError?.message || rawGraphQLError?.message || ''
 
-    Logger.error(!message ? title : `${title}: ${message}`, { ...error, ...contexts })
+    ConsoleLogger.error(!message ? title : `${title}: ${message}`, { ...error, ...contexts })
+
+    if (!this.initialized) {
+      ConsoleLogger.debug("Skipping Sentry error capture because SentryLogger wasn't initialized")
+      return
+    }
 
-    Sentry.captureException(new CustomError(title, message), {
+    Sentry.captureException(new SentryError(title, message), {
       contexts: {
         error,
         ...contexts,
@@ -86,7 +78,15 @@ export class Logger {
     })
   }
 
-  static captureMessage = (message: string, source: string, level: LogMessageLevel, contexts?: LogContexts) => {
+  message(message: string, source: string, level: LogMessageLevel, contexts?: LogContexts) {
+    const logFn = level === 'error' ? ConsoleLogger.error : level === 'warning' ? ConsoleLogger.warn : ConsoleLogger.log
+    logFn(message, contexts)
+
+    if (!this.initialized) {
+      ConsoleLogger.debug("Skipping Sentry message capture because SentryLogger wasn't initialized")
+      return
+    }
+
     Sentry.captureMessage(message, {
       level: Severity.fromString(level),
       contexts,
@@ -96,10 +96,4 @@ export class Logger {
   }
 }
 
-const getUserInfo = (): Record<string, unknown> => {
-  const { actions, ...userState } = useActiveUserStore.getState()
-  return {
-    ip_address: '{{auto}}',
-    ...userState,
-  }
-}
+export const SentryLogger = new _SentryLogger()

+ 16 - 0
src/utils/logs/shared.ts

@@ -0,0 +1,16 @@
+import { useActiveUserStore } from '@/providers/user/store'
+
+export const getLogArgs = (message: string, details?: unknown) => {
+  if (details) {
+    return [message, details]
+  }
+  return [message]
+}
+
+export const getUserInfo = (includeIp = true): Record<string, unknown> => {
+  const { actions, ...userState } = useActiveUserStore.getState()
+  return {
+    ...userState,
+    ...(includeIp ? { ip_address: '{{auto}}' } : {}),
+  }
+}

+ 3 - 1
src/utils/misc.ts

@@ -1,4 +1,6 @@
+export class TimeoutError extends Error {}
+
 export const withTimeout = async <T>(promise: Promise<T>, timeout: number) => {
-  const timeoutPromise = new Promise<T>((resolve, reject) => setTimeout(() => reject(new Error('Timed out!')), timeout))
+  const timeoutPromise = new Promise<T>((resolve, reject) => setTimeout(() => reject(new TimeoutError()), timeout))
   return await Promise.race([timeoutPromise, promise])
 }

+ 2 - 2
src/views/admin/AdminView.tsx

@@ -5,7 +5,7 @@ import { TARGET_DEV_ENV, availableEnvs, setEnvInLocalStorage } from '@/config/en
 import { absoluteRoutes } from '@/config/routes'
 import { useSnackbar } from '@/providers'
 import { Button, Select, Text } from '@/shared/components'
-import { Logger } from '@/utils/logger'
+import { SentryLogger } from '@/utils/logs'
 
 const items = availableEnvs().map((item) => ({ name: item, value: item }))
 
@@ -58,7 +58,7 @@ export const AdminView = () => {
         iconType: 'success',
       })
     } catch (error) {
-      Logger.captureError('Failed to import local state', 'AdminView', error)
+      SentryLogger.error('Failed to import local state', 'AdminView', error)
       displaySnackbar({
         title: 'JSON file seems to be corrupted',
         description: 'Please try again with different file',

+ 2 - 2
src/views/playground/Playgrounds/PlaygroundConnectionState.tsx

@@ -4,11 +4,11 @@ import { BaseDialog } from '@/components/Dialogs'
 import { absoluteRoutes } from '@/config/routes'
 import { useConnectionStatusStore } from '@/providers'
 import { Button, Text } from '@/shared/components'
-import { Logger } from '@/utils/logger'
+import { ConsoleLogger } from '@/utils/logs'
 
 const fakeNodeConnection = async () => {
   await new Promise((resolve) => setTimeout(resolve, 3000))
-  Logger.log('disconnected from node')
+  ConsoleLogger.log('disconnected from node')
   return false
 }
 

+ 2 - 2
src/views/playground/Playgrounds/PlaygroundValidationForm.tsx

@@ -16,7 +16,7 @@ import {
 } from '@/shared/components'
 import { SelectItem } from '@/shared/components/Select'
 import { textFieldValidation } from '@/utils/formValidationOptions'
-import { Logger } from '@/utils/logger'
+import { ConsoleLogger } from '@/utils/logs'
 
 const items: SelectItem<boolean>[] = [
   { name: 'Public (Anyone can see this video', value: true },
@@ -56,7 +56,7 @@ export const PlaygroundValidationForm = () => {
   })
 
   const onSubmit = handleSubmit((data) => {
-    Logger.log('Playground validation form data:', data)
+    ConsoleLogger.log('Playground validation form data:', data)
     reset()
   })
 

+ 3 - 3
src/views/studio/CreateEditChannelView/CreateEditChannelView.tsx

@@ -43,7 +43,7 @@ import { AssetDimensions, ImageCropData } from '@/types/cropper'
 import { createId } from '@/utils/createId'
 import { requiredValidation, textFieldValidation } from '@/utils/formValidationOptions'
 import { computeFileHash } from '@/utils/hashing'
-import { Logger } from '@/utils/logger'
+import { SentryLogger } from '@/utils/logs'
 import { formatNumberShort } from '@/utils/number'
 import { SubTitleSkeletonLoader, TitleSkeletonLoader } from '@/views/viewer/ChannelView/ChannelView.style'
 
@@ -96,7 +96,7 @@ export const CreateEditChannelView: React.FC<CreateEditChannelViewProps> = ({ ne
   const { channel, loading, error, refetch: refetchChannel } = useChannel(activeChannelId || '', {
     skip: newChannel || !activeChannelId,
     onError: (error) =>
-      Logger.captureError('Failed to fetch channel', 'CreateEditChannelView', error, {
+      SentryLogger.error('Failed to fetch channel', 'CreateEditChannelView', error, {
         channel: { id: activeChannelId },
       }),
   })
@@ -297,7 +297,7 @@ export const CreateEditChannelView: React.FC<CreateEditChannelViewProps> = ({ ne
         uploadPromises.push(uploadPromise)
       }
       Promise.all(uploadPromises).catch((e) =>
-        Logger.captureError('Unexpected upload failure', 'CreateEditChannelView', e)
+        SentryLogger.error('Unexpected upload failure', 'CreateEditChannelView', e)
       )
     }
 

+ 3 - 3
src/views/studio/CreateMemberView/CreateMemberView.tsx

@@ -16,7 +16,7 @@ import { useConnectionStatusStore, useDialog, useUser } from '@/providers'
 import { Spinner } from '@/shared/components'
 import { TextArea } from '@/shared/components/TextArea'
 import { textFieldValidation } from '@/utils/formValidationOptions'
-import { Logger } from '@/utils/logger'
+import { SentryLogger } from '@/utils/logs'
 
 import {
   Form,
@@ -68,7 +68,7 @@ export const CreateMemberView = () => {
   // subscription doesn't allow 'onError' callback
   useEffect(() => {
     if (!queryNodeStateError) return
-    Logger.captureError('Failed to subscribe to query node state', 'CreateMemberView', queryNodeStateError)
+    SentryLogger.error('Failed to subscribe to query node state', 'CreateMemberView', queryNodeStateError)
   }, [queryNodeStateError])
 
   const client = useApolloClient()
@@ -230,7 +230,7 @@ export const createNewMember = async (accountId: string, inputs: Inputs) => {
     const response = await axios.post<NewMemberResponse>(FAUCET_URL, body)
     return response.data
   } catch (error) {
-    Logger.captureError('Failed to create a membership', 'CreateMemberView', error)
+    SentryLogger.error('Failed to create a membership', 'CreateMemberView', error)
     throw error
   }
 }

+ 3 - 3
src/views/studio/EditVideoSheet/EditVideoForm/EditVideoForm.tsx

@@ -36,7 +36,7 @@ import { FileErrorType, ImageInputFile, VideoInputFile } from '@/shared/componen
 import { SvgGlyphInfo } from '@/shared/icons'
 import { createId } from '@/utils/createId'
 import { pastDateValidation, requiredValidation, textFieldValidation } from '@/utils/formValidationOptions'
-import { Logger } from '@/utils/logger'
+import { SentryLogger } from '@/utils/logs'
 import { StyledActionBar } from '@/views/studio/EditVideoSheet/EditVideoSheet.style'
 
 import {
@@ -108,7 +108,7 @@ export const EditVideoForm: React.FC<EditVideoFormProps> = ({
   const deleteVideo = useDeleteVideo()
 
   const { categories, error: categoriesError } = useCategories(undefined, {
-    onError: (error) => Logger.captureError('Failed to fetch categories', 'EditVideoSheet', error),
+    onError: (error) => SentryLogger.error('Failed to fetch categories', 'EditVideoSheet', error),
   })
   const { tabData, loading: tabDataLoading, error: tabDataError } = useEditVideoSheetTabData(selectedVideoTab)
 
@@ -396,7 +396,7 @@ export const EditVideoForm: React.FC<EditVideoFormProps> = ({
     } else if (errorCode === 'file-too-large') {
       setFileSelectError('File too large')
     } else {
-      Logger.captureError('Unknown file select error', 'EditVideoForm', null, { error: { code: errorCode } })
+      SentryLogger.error('Unknown file select error', 'EditVideoForm', null, { error: { code: errorCode } })
       setFileSelectError('Unknown error')
     }
   }

+ 4 - 4
src/views/studio/EditVideoSheet/EditVideoSheet.tsx

@@ -25,7 +25,7 @@ import {
 } from '@/providers'
 import { writeVideoDataInCache } from '@/utils/cachingAssets'
 import { computeFileHash } from '@/utils/hashing'
-import { Logger } from '@/utils/logger'
+import { ConsoleLogger, SentryLogger } from '@/utils/logs'
 
 import { EditVideoForm } from './EditVideoForm'
 import { Container, DrawerOverlay } from './EditVideoSheet.style'
@@ -152,7 +152,7 @@ export const EditVideoSheet: React.FC = () => {
         assets.video = asset
         videoContentId = contentId
       } else if (dirtyFields.assets?.video) {
-        Logger.warn('Missing video data')
+        ConsoleLogger.warn('Missing video data')
       }
 
       if (thumbnailAsset?.blob && thumbnailHashPromise) {
@@ -163,7 +163,7 @@ export const EditVideoSheet: React.FC = () => {
         assets.thumbnail = asset
         thumbnailContentId = contentId
       } else if (dirtyFields.assets?.thumbnail) {
-        Logger.warn('Missing thumbnail data')
+        ConsoleLogger.warn('Missing thumbnail data')
       }
     }
 
@@ -197,7 +197,7 @@ export const EditVideoSheet: React.FC = () => {
         })
         uploadPromises.push(uploadPromise)
       }
-      Promise.all(uploadPromises).catch((e) => Logger.captureError('Unexpected upload failure', 'EditVideoSheet', e))
+      Promise.all(uploadPromises).catch((e) => SentryLogger.error('Unexpected upload failure', 'EditVideoSheet', e))
     }
 
     const refetchDataAndCacheAssets = async (videoId: VideoId) => {

+ 2 - 2
src/views/studio/MyVideosView/MyVideosView.tsx

@@ -18,7 +18,7 @@ import {
 } from '@/providers'
 import { Button, EmptyFallback, Grid, Pagination, Select, Tabs, Text } from '@/shared/components'
 import { SvgGlyphUpload } from '@/shared/icons'
-import { Logger } from '@/utils/logger'
+import { SentryLogger } from '@/utils/logs'
 
 import {
   PaginationContainer,
@@ -75,7 +75,7 @@ export const MyVideosView = () => {
     },
     {
       notifyOnNetworkStatusChange: true,
-      onError: (error) => Logger.captureError('Failed to fetch videos', 'MyVideosView', error),
+      onError: (error) => SentryLogger.error('Failed to fetch videos', 'MyVideosView', error),
     }
   )
   const [openDeleteDraftDialog, closeDeleteDraftDialog] = useDialog()

+ 6 - 6
src/views/viewer/ChannelView/ChannelView.tsx

@@ -23,7 +23,7 @@ import { AssetType, useAsset, useDialog, usePersonalDataStore } from '@/provider
 import { Button, ChannelCover, EmptyFallback, Grid, Pagination, Select, Text } from '@/shared/components'
 import { SvgGlyphCheck, SvgGlyphPlus, SvgGlyphSearch } from '@/shared/icons'
 import { transitions } from '@/shared/theme'
-import { Logger } from '@/utils/logger'
+import { SentryLogger } from '@/utils/logs'
 import { formatNumberShort } from '@/utils/number'
 
 import { ChannelAbout } from './ChannelAbout'
@@ -60,7 +60,7 @@ export const ChannelView: React.FC = () => {
   const { id } = useParams()
   const [searchParams, setSearchParams] = useSearchParams()
   const { channel, loading, error } = useChannel(id, {
-    onError: (error) => Logger.captureError('Failed to fetch channel', 'ChannelView', error, { channel: { id } }),
+    onError: (error) => SentryLogger.error('Failed to fetch channel', 'ChannelView', error, { channel: { id } }),
   })
   const {
     searchVideos,
@@ -76,7 +76,7 @@ export const ChannelView: React.FC = () => {
   } = useSearchVideos({
     id,
     onError: (error) =>
-      Logger.captureError('Failed to search channel videos', 'ChannelView', error, {
+      SentryLogger.error('Failed to search channel videos', 'ChannelView', error, {
         search: { channelId: id, query: searchQuery },
       }),
   })
@@ -116,11 +116,11 @@ export const ChannelView: React.FC = () => {
     },
     {
       notifyOnNetworkStatusChange: true,
-      onError: (error) => Logger.captureError('Failed to fetch videos', 'ChannelView', error, { channel: { id } }),
+      onError: (error) => SentryLogger.error('Failed to fetch videos', 'ChannelView', error, { channel: { id } }),
     }
   )
   const { videoCount: videosLastMonth } = useChannelVideoCount(id, DATE_ONE_MONTH_PAST, {
-    onError: (error) => Logger.captureError('Failed to fetch videos', 'ChannelView', error, { channel: { id } }),
+    onError: (error) => SentryLogger.error('Failed to fetch videos', 'ChannelView', error, { channel: { id } }),
   })
   useEffect(() => {
     const isFollowing = followedChannels.some((channel) => channel.id === id)
@@ -169,7 +169,7 @@ export const ChannelView: React.FC = () => {
         setFollowing(true)
       }
     } catch (error) {
-      Logger.captureError('Failed to update channel following', 'ChannelView', error, { channel: { id } })
+      SentryLogger.error('Failed to update channel following', 'ChannelView', error, { channel: { id } })
     }
   }
 

+ 2 - 2
src/views/viewer/HomeView.tsx

@@ -12,7 +12,7 @@ import {
 } from '@/components'
 import { usePersonalDataStore } from '@/providers'
 import { transitions } from '@/shared/theme'
-import { Logger } from '@/utils/logger'
+import { SentryLogger } from '@/utils/logs'
 
 const MIN_FOLLOWED_CHANNELS_VIDEOS = 16
 // last three months
@@ -31,7 +31,7 @@ export const HomeView: React.FC = () => {
         createdAt_gte: MIN_DATE_FOLLOWED_CHANNELS_VIDEOS,
       },
     },
-    { skip: !anyFollowedChannels, onError: (error) => Logger.captureError('Failed to fetch videos', 'HomeView', error) }
+    { skip: !anyFollowedChannels, onError: (error) => SentryLogger.error('Failed to fetch videos', 'HomeView', error) }
   )
 
   const followedChannelsVideosCount = videosConnection?.totalCount

+ 2 - 2
src/views/viewer/SearchOverlayView/SearchResults/SearchResults.tsx

@@ -7,7 +7,7 @@ import { ChannelGrid, SkeletonLoaderVideoGrid, VideoGrid, ViewErrorFallback, Vie
 import { usePersonalDataStore } from '@/providers'
 import { Tabs } from '@/shared/components'
 import { sizes } from '@/shared/theme'
-import { Logger } from '@/utils/logger'
+import { SentryLogger } from '@/utils/logs'
 
 import { AllResultsTab } from './AllResultsTab'
 import { EmptyFallback } from './EmptyFallback'
@@ -29,7 +29,7 @@ export const SearchResults: React.FC<SearchResultsProps> = ({ query }) => {
       },
       whereChannel: {},
     },
-    { onError: (error) => Logger.captureError('Failed to fetch search results', 'SearchResults', error) }
+    { onError: (error) => SentryLogger.error('Failed to fetch search results', 'SearchResults', error) }
   )
 
   const getChannelsAndVideos = (loading: boolean, data: SearchQuery['search'] | undefined) => {

+ 3 - 3
src/views/viewer/VideoView/VideoView.tsx

@@ -10,7 +10,7 @@ import { useRouterQuery } from '@/hooks'
 import { AssetType, useAsset, usePersonalDataStore } from '@/providers'
 import { Button, EmptyFallback, SkeletonLoader, VideoPlayer } from '@/shared/components'
 import { transitions } from '@/shared/theme'
-import { Logger } from '@/utils/logger'
+import { SentryLogger } from '@/utils/logs'
 import { formatVideoViewsAndDate } from '@/utils/video'
 
 import {
@@ -33,7 +33,7 @@ import {
 export const VideoView: React.FC = () => {
   const { id } = useParams()
   const { loading, video, error } = useVideo(id, {
-    onError: (error) => Logger.captureError('Failed to load video data', 'VideoView', error),
+    onError: (error) => SentryLogger.error('Failed to load video data', 'VideoView', error),
   })
   const { addVideoView } = useAddVideoView()
   const watchedVideos = usePersonalDataStore((state) => state.watchedVideos)
@@ -78,7 +78,7 @@ export const VideoView: React.FC = () => {
         channelId,
       },
     }).catch((error) => {
-      Logger.captureError('Failed to increase video views', 'VideoView', error)
+      SentryLogger.error('Failed to increase video views', 'VideoView', error)
     })
   }, [addVideoView, videoId, channelId])
 

+ 3 - 3
src/views/viewer/VideosView/VideosView.tsx

@@ -6,7 +6,7 @@ import { VideoOrderByInput } from '@/api/queries'
 import { BackgroundPattern, TOP_NAVBAR_HEIGHT, VideoGallery, ViewErrorFallback } from '@/components'
 import { Text } from '@/shared/components'
 import { transitions } from '@/shared/theme'
-import { Logger } from '@/utils/logger'
+import { SentryLogger } from '@/utils/logs'
 
 import {
   CategoriesVideosContainer,
@@ -22,7 +22,7 @@ import {
 export const VideosView: React.FC = () => {
   const [selectedCategoryId, setSelectedCategoryId] = useState<string | null>(null)
   const { loading: categoriesLoading, categories, error: categoriesError } = useCategories(undefined, {
-    onError: (error) => Logger.captureError('Failed to fetch categories', 'VideosView', error),
+    onError: (error) => SentryLogger.error('Failed to fetch categories', 'VideosView', error),
   })
   const { loading: featuredVideosLoading, videos: featuredVideos, error: videosError } = useVideos(
     {
@@ -33,7 +33,7 @@ export const VideosView: React.FC = () => {
     },
     {
       notifyOnNetworkStatusChange: true,
-      onError: (error) => Logger.captureError('Failed to fetch videos', 'VideosView', error),
+      onError: (error) => SentryLogger.error('Failed to fetch videos', 'VideosView', error),
     }
   )