From 96f4f6359add6a4f2a37df8f17cf3f2f59f0a2a6 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Fri, 28 Feb 2025 17:13:49 -0600 Subject: Logger metrics (#7867) * Adjust datalake abstraction (cherry picked from commit 8ba6a8d45b1bd5698afbd06d9e858a91789f0ea6) * Just be really really specific (cherry picked from commit 920198959659329a7f7f7282a1293aaad198d8e3) * Add metric method to logger, replace datalake calls with new method (cherry picked from commit 7a026bbeae75514b64f928d7ff59707c518fd5e5) * Clarify types (cherry picked from commit 422b150deb158a70ef37e8a456d91bf26cd0b1bc) --- src/Navigation.tsx | 5 +- src/lib/statsig/events.ts | 308 -------------------------------------- src/lib/statsig/statsig.tsx | 35 ++++- src/logger/index.ts | 21 +++ src/logger/metrics.ts | 308 ++++++++++++++++++++++++++++++++++++++ src/logger/transports/bitdrift.ts | 3 +- src/logger/transports/sentry.ts | 3 +- src/logger/types.ts | 10 +- 8 files changed, 369 insertions(+), 324 deletions(-) delete mode 100644 src/lib/statsig/events.ts create mode 100644 src/logger/metrics.ts (limited to 'src') diff --git a/src/Navigation.tsx b/src/Navigation.tsx index cf09406a6..baf99f110 100644 --- a/src/Navigation.tsx +++ b/src/Navigation.tsx @@ -32,6 +32,7 @@ import { import {RouteParams, State} from '#/lib/routes/types' import {attachRouteToLogEvents, logEvent} from '#/lib/statsig/statsig' import {bskyTitle} from '#/lib/strings/headings' +import {logger} from '#/logger' import {isNative, isWeb} from '#/platform/detection' import {useModalControls} from '#/state/modals' import {useUnreadNotifications} from '#/state/queries/notifications/unread' @@ -729,7 +730,7 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) { linking={LINKING} theme={theme} onStateChange={() => { - logEvent('lake:router:navigate', { + logger.metric('router:navigate', { from: prevLoggedRouteName.current, }) prevLoggedRouteName.current = getCurrentRouteName() @@ -738,7 +739,7 @@ function RoutesContainer({children}: React.PropsWithChildren<{}>) { attachRouteToLogEvents(getCurrentRouteName) logModuleInitTime() onReady() - logEvent('lake:router:navigate', {}) + logger.metric('router:navigate', {}) }}> {children} diff --git a/src/lib/statsig/events.ts b/src/lib/statsig/events.ts deleted file mode 100644 index 519e3997e..000000000 --- a/src/lib/statsig/events.ts +++ /dev/null @@ -1,308 +0,0 @@ -export type LogEvents = { - // App events - init: { - initMs: number - } - 'account:loggedIn': { - logContext: - | 'LoginForm' - | 'SwitchAccount' - | 'ChooseAccountForm' - | 'Settings' - | 'Notification' - withPassword: boolean - } - 'account:loggedOut': { - logContext: - | 'SwitchAccount' - | 'Settings' - | 'SignupQueued' - | 'Deactivated' - | 'Takendown' - scope: 'current' | 'every' - } - 'notifications:openApp': {} - 'notifications:request': { - context: 'StartOnboarding' | 'AfterOnboarding' | 'Login' | 'Home' - status: 'granted' | 'denied' | 'undetermined' - } - 'state:background': { - secondsActive: number - } - 'state:foreground': {} - 'lake:router:navigate': { - from?: string - } - 'deepLink:referrerReceived': { - to: string - referrer: string - hostname: string - } - - // Screen events - 'splash:signInPressed': {} - 'splash:createAccountPressed': {} - 'signup:nextPressed': { - activeStep: number - phoneVerificationRequired?: boolean - } - 'signup:backPressed': { - activeStep: number - } - 'signup:captchaSuccess': {} - 'signup:captchaFailure': {} - 'signin:hostingProviderPressed': { - hostingProviderDidChange: boolean - } - 'signin:hostingProviderFailedResolution': {} - 'signin:success': { - failedAttemptsCount: number - isUsingCustomProvider: boolean - timeTakenSeconds: number - } - 'signin:backPressed': { - failedAttemptsCount: number - } - 'signin:forgotPasswordPressed': {} - 'signin:passwordReset': {} - 'signin:passwordResetSuccess': {} - 'signin:passwordResetFailure': {} - 'onboarding:interests:nextPressed': { - selectedInterests: string[] - selectedInterestsLength: number - } - 'onboarding:suggestedAccounts:nextPressed': { - selectedAccountsLength: number - skipped: boolean - } - 'onboarding:followingFeed:nextPressed': {} - 'onboarding:algoFeeds:nextPressed': { - selectedPrimaryFeeds: string[] - selectedPrimaryFeedsLength: number - selectedSecondaryFeeds: string[] - selectedSecondaryFeedsLength: number - } - 'onboarding:topicalFeeds:nextPressed': { - selectedFeeds: string[] - selectedFeedsLength: number - } - 'onboarding:moderation:nextPressed': {} - 'onboarding:profile:nextPressed': {} - 'onboarding:finished:nextPressed': { - usedStarterPack: boolean - starterPackName?: string - starterPackCreator?: string - starterPackUri?: string - profilesFollowed: number - feedsPinned: number - } - 'onboarding:finished:avatarResult': { - avatarResult: 'default' | 'created' | 'uploaded' - } - 'home:feedDisplayed': { - feedUrl: string - feedType: string - index: number - } - 'feed:endReached': { - feedUrl: string - feedType: string - itemCount: number - } - 'feed:refresh': { - feedUrl: string - feedType: string - reason: 'pull-to-refresh' | 'soft-reset' | 'load-latest' - } - 'discover:showMore': { - feedContext: string - } - 'discover:showLess': { - feedContext: string - } - 'discover:clickthrough': { - count: number - } - 'discover:engaged': { - count: number - } - 'discover:seen': { - count: number - } - - 'composer:gif:open': {} - 'composer:gif:select': {} - - // Data events - 'account:create:begin': {} - 'account:create:success': {} - 'post:create': { - imageCount: number - isReply: boolean - isPartOfThread: boolean - hasLink: boolean - hasQuote: boolean - langs: string - logContext: 'Composer' - } - 'thread:create': { - postCount: number - isReply: boolean - } - 'post:like': { - doesLikerFollowPoster: boolean | undefined - doesPosterFollowLiker: boolean | undefined - likerClout: number | undefined - postClout: number | undefined - logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' - } - 'post:repost': { - logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' - } - 'post:unlike': { - logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' - } - 'post:unrepost': { - logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' - } - 'post:mute': {} - 'post:unmute': {} - 'post:pin': {} - 'post:unpin': {} - 'profile:follow': { - didBecomeMutual: boolean | undefined - followeeClout: number | undefined - followerClout: number | undefined - logContext: - | 'RecommendedFollowsItem' - | 'PostThreadItem' - | 'ProfileCard' - | 'ProfileHeader' - | 'ProfileHeaderSuggestedFollows' - | 'ProfileMenu' - | 'ProfileHoverCard' - | 'AvatarButton' - | 'StarterPackProfilesList' - | 'FeedInterstitial' - | 'ProfileHeaderSuggestedFollows' - | 'PostOnboardingFindFollows' - | 'ImmersiveVideo' - } - 'suggestedUser:follow': { - logContext: - | 'Explore' - | 'InterstitialDiscover' - | 'InterstitialProfile' - | 'Profile' - location: 'Card' | 'Profile' - recId?: number - position: number - } - 'suggestedUser:press': { - logContext: 'Explore' | 'InterstitialDiscover' | 'InterstitialProfile' - recId?: number - position: number - } - 'suggestedUser:seen': { - logContext: 'Explore' | 'InterstitialDiscover' | 'InterstitialProfile' - recId?: number - position: number - } - 'profile:unfollow': { - logContext: - | 'RecommendedFollowsItem' - | 'PostThreadItem' - | 'ProfileCard' - | 'ProfileHeader' - | 'ProfileHeaderSuggestedFollows' - | 'ProfileMenu' - | 'ProfileHoverCard' - | 'Chat' - | 'AvatarButton' - | 'StarterPackProfilesList' - | 'FeedInterstitial' - | 'ProfileHeaderSuggestedFollows' - | 'PostOnboardingFindFollows' - | 'ImmersiveVideo' - } - 'chat:create': { - logContext: 'ProfileHeader' | 'NewChatDialog' | 'SendViaChatDialog' - } - 'chat:open': { - logContext: - | 'ProfileHeader' - | 'NewChatDialog' - | 'ChatsList' - | 'SendViaChatDialog' - } - 'starterPack:share': { - starterPack: string - shareType: 'link' | 'qrcode' - qrShareType?: 'save' | 'copy' | 'share' - } - 'starterPack:followAll': { - logContext: 'StarterPackProfilesList' | 'Onboarding' - starterPack: string - count: number - } - 'starterPack:delete': {} - 'starterPack:create': { - setName: boolean - setDescription: boolean - profilesCount: number - feedsCount: number - } - 'starterPack:ctaPress': { - starterPack: string - } - 'starterPack:opened': { - starterPack: string - } - 'link:clicked': { - url: string - domain: string - } - - 'feed:interstitial:feedCard:press': {} - - 'profile:header:suggestedFollowsCard:press': {} - - 'test:all:always': {} - 'test:all:sometimes': {} - 'test:all:boosted_by_gate1': {reason: 'base' | 'gate1'} - 'test:all:boosted_by_gate2': {reason: 'base' | 'gate2'} - 'test:all:boosted_by_both': {reason: 'base' | 'gate1' | 'gate2'} - 'test:gate1:always': {} - 'test:gate1:sometimes': {} - 'test:gate2:always': {} - 'test:gate2:sometimes': {} - - 'tmd:share': {} - 'tmd:download': {} - 'tmd:post': {} - - 'trendingTopics:show': { - context: 'settings' - } - 'trendingTopics:hide': { - context: 'settings' | 'sidebar' | 'interstitial' | 'explore:trending' - } - 'trendingTopic:click': { - context: 'sidebar' | 'interstitial' | 'explore' - } - 'recommendedTopic:click': { - context: 'explore' - } - 'trendingVideos:show': { - context: 'settings' - } - 'trendingVideos:hide': { - context: 'settings' | 'interstitial:discover' | 'interstitial:explore' - } - 'videoCard:click': { - context: 'interstitial:discover' | 'interstitial:explore' | 'feed' - } - - 'progressGuide:hide': {} - 'progressGuide:followDialog:open': {} -} diff --git a/src/lib/statsig/statsig.tsx b/src/lib/statsig/statsig.tsx index a64c710ea..7f04da126 100644 --- a/src/lib/statsig/statsig.tsx +++ b/src/lib/statsig/statsig.tsx @@ -5,12 +5,12 @@ import {Statsig, StatsigProvider} from 'statsig-react-native-expo' import {BUNDLE_DATE, BUNDLE_IDENTIFIER, IS_TESTFLIGHT} from '#/lib/app-info' import {logger} from '#/logger' +import {MetricEvents} from '#/logger/metrics' import {isWeb} from '#/platform/detection' import * as persisted from '#/state/persisted' import {useSession} from '../../state/session' import {timeout} from '../async/timeout' import {useNonReactiveCallback} from '../hooks/useNonReactiveCallback' -import {LogEvents} from './events' import {Gate} from './gates' const SDK_KEY = 'client-SXJakO39w9vIhl3D44u8UupyzFl4oZ2qPIkjwcvuPsV' @@ -42,7 +42,7 @@ if (isWeb && typeof window !== 'undefined') { refUrl = decodeURIComponent(params.get('ref_url') ?? '') } -export type {LogEvents} +export type {MetricEvents as LogEvents} function createStatsigOptions(prefetchUsers: StatsigUser[]) { return { @@ -91,25 +91,44 @@ export function toClout(n: number | null | undefined): number | undefined { } } -export function logEvent( +/** + * @deprecated use `logger.metric()` instead + */ +export function logEvent( eventName: E & string, - rawMetadata: LogEvents[E] & FlatJSONRecord, + rawMetadata: MetricEvents[E] & FlatJSONRecord, + options: { + /** + * Send to our data lake only, not to StatSig + */ + lake?: boolean + } = {lake: false}, ) { try { const fullMetadata = toStringRecord(rawMetadata) fullMetadata.routeName = getCurrentRouteName() ?? '(Uninitialized)' if (Statsig.initializeCalled()) { - Statsig.logEvent(eventName, null, fullMetadata) + let ev: string = eventName + if (options.lake) { + ev = `lake:${ev}` + } + Statsig.logEvent(ev, null, fullMetadata) + } + /** + * All datalake events should be sent using `logger.metric`, and we don't + * want to double-emit logs to other transports. + */ + if (!options.lake) { + logger.info(eventName, fullMetadata) } - logger.info(eventName, fullMetadata) } catch (e) { // A log should never interrupt the calling code, whatever happens. logger.error('Failed to log an event', {message: e}) } } -function toStringRecord( - metadata: LogEvents[E] & FlatJSONRecord, +function toStringRecord( + metadata: MetricEvents[E] & FlatJSONRecord, ): Record { const record: Record = {} for (let key in metadata) { diff --git a/src/logger/index.ts b/src/logger/index.ts index 410d29bb3..0a50a9d21 100644 --- a/src/logger/index.ts +++ b/src/logger/index.ts @@ -1,6 +1,8 @@ import {nanoid} from 'nanoid/non-secure' +import {logEvent} from '#/lib/statsig/statsig' import {add} from '#/logger/logDump' +import {MetricEvents} from '#/logger/metrics' import {bitdriftTransport} from '#/logger/transports/bitdrift' import {consoleTransport} from '#/logger/transports/console' import {sentryTransport} from '#/logger/transports/sentry' @@ -89,6 +91,25 @@ export class Logger { this.transport({level: LogLevel.Error, message: error, metadata}) } + metric( + event: E & string, + metadata: MetricEvents[E], + options: { + /** + * Optionally also send to StatSig + */ + statsig?: boolean + } = {statsig: false}, + ) { + logEvent(event, metadata, { + lake: !options.statsig, + }) + + for (const transport of this.transports) { + transport(LogLevel.Info, LogContext.Metric, event, metadata, Date.now()) + } + } + addTransport(transport: Transport) { this.transports.push(transport) return () => { diff --git a/src/logger/metrics.ts b/src/logger/metrics.ts new file mode 100644 index 000000000..e3bd93314 --- /dev/null +++ b/src/logger/metrics.ts @@ -0,0 +1,308 @@ +export type MetricEvents = { + // App events + init: { + initMs: number + } + 'account:loggedIn': { + logContext: + | 'LoginForm' + | 'SwitchAccount' + | 'ChooseAccountForm' + | 'Settings' + | 'Notification' + withPassword: boolean + } + 'account:loggedOut': { + logContext: + | 'SwitchAccount' + | 'Settings' + | 'SignupQueued' + | 'Deactivated' + | 'Takendown' + scope: 'current' | 'every' + } + 'notifications:openApp': {} + 'notifications:request': { + context: 'StartOnboarding' | 'AfterOnboarding' | 'Login' | 'Home' + status: 'granted' | 'denied' | 'undetermined' + } + 'state:background': { + secondsActive: number + } + 'state:foreground': {} + 'router:navigate': { + from?: string + } + 'deepLink:referrerReceived': { + to: string + referrer: string + hostname: string + } + + // Screen events + 'splash:signInPressed': {} + 'splash:createAccountPressed': {} + 'signup:nextPressed': { + activeStep: number + phoneVerificationRequired?: boolean + } + 'signup:backPressed': { + activeStep: number + } + 'signup:captchaSuccess': {} + 'signup:captchaFailure': {} + 'signin:hostingProviderPressed': { + hostingProviderDidChange: boolean + } + 'signin:hostingProviderFailedResolution': {} + 'signin:success': { + failedAttemptsCount: number + isUsingCustomProvider: boolean + timeTakenSeconds: number + } + 'signin:backPressed': { + failedAttemptsCount: number + } + 'signin:forgotPasswordPressed': {} + 'signin:passwordReset': {} + 'signin:passwordResetSuccess': {} + 'signin:passwordResetFailure': {} + 'onboarding:interests:nextPressed': { + selectedInterests: string[] + selectedInterestsLength: number + } + 'onboarding:suggestedAccounts:nextPressed': { + selectedAccountsLength: number + skipped: boolean + } + 'onboarding:followingFeed:nextPressed': {} + 'onboarding:algoFeeds:nextPressed': { + selectedPrimaryFeeds: string[] + selectedPrimaryFeedsLength: number + selectedSecondaryFeeds: string[] + selectedSecondaryFeedsLength: number + } + 'onboarding:topicalFeeds:nextPressed': { + selectedFeeds: string[] + selectedFeedsLength: number + } + 'onboarding:moderation:nextPressed': {} + 'onboarding:profile:nextPressed': {} + 'onboarding:finished:nextPressed': { + usedStarterPack: boolean + starterPackName?: string + starterPackCreator?: string + starterPackUri?: string + profilesFollowed: number + feedsPinned: number + } + 'onboarding:finished:avatarResult': { + avatarResult: 'default' | 'created' | 'uploaded' + } + 'home:feedDisplayed': { + feedUrl: string + feedType: string + index: number + } + 'feed:endReached': { + feedUrl: string + feedType: string + itemCount: number + } + 'feed:refresh': { + feedUrl: string + feedType: string + reason: 'pull-to-refresh' | 'soft-reset' | 'load-latest' + } + 'discover:showMore': { + feedContext: string + } + 'discover:showLess': { + feedContext: string + } + 'discover:clickthrough': { + count: number + } + 'discover:engaged': { + count: number + } + 'discover:seen': { + count: number + } + + 'composer:gif:open': {} + 'composer:gif:select': {} + + // Data events + 'account:create:begin': {} + 'account:create:success': {} + 'post:create': { + imageCount: number + isReply: boolean + isPartOfThread: boolean + hasLink: boolean + hasQuote: boolean + langs: string + logContext: 'Composer' + } + 'thread:create': { + postCount: number + isReply: boolean + } + 'post:like': { + doesLikerFollowPoster: boolean | undefined + doesPosterFollowLiker: boolean | undefined + likerClout: number | undefined + postClout: number | undefined + logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' + } + 'post:repost': { + logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' + } + 'post:unlike': { + logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' + } + 'post:unrepost': { + logContext: 'FeedItem' | 'PostThreadItem' | 'Post' | 'ImmersiveVideo' + } + 'post:mute': {} + 'post:unmute': {} + 'post:pin': {} + 'post:unpin': {} + 'profile:follow': { + didBecomeMutual: boolean | undefined + followeeClout: number | undefined + followerClout: number | undefined + logContext: + | 'RecommendedFollowsItem' + | 'PostThreadItem' + | 'ProfileCard' + | 'ProfileHeader' + | 'ProfileHeaderSuggestedFollows' + | 'ProfileMenu' + | 'ProfileHoverCard' + | 'AvatarButton' + | 'StarterPackProfilesList' + | 'FeedInterstitial' + | 'ProfileHeaderSuggestedFollows' + | 'PostOnboardingFindFollows' + | 'ImmersiveVideo' + } + 'suggestedUser:follow': { + logContext: + | 'Explore' + | 'InterstitialDiscover' + | 'InterstitialProfile' + | 'Profile' + location: 'Card' | 'Profile' + recId?: number + position: number + } + 'suggestedUser:press': { + logContext: 'Explore' | 'InterstitialDiscover' | 'InterstitialProfile' + recId?: number + position: number + } + 'suggestedUser:seen': { + logContext: 'Explore' | 'InterstitialDiscover' | 'InterstitialProfile' + recId?: number + position: number + } + 'profile:unfollow': { + logContext: + | 'RecommendedFollowsItem' + | 'PostThreadItem' + | 'ProfileCard' + | 'ProfileHeader' + | 'ProfileHeaderSuggestedFollows' + | 'ProfileMenu' + | 'ProfileHoverCard' + | 'Chat' + | 'AvatarButton' + | 'StarterPackProfilesList' + | 'FeedInterstitial' + | 'ProfileHeaderSuggestedFollows' + | 'PostOnboardingFindFollows' + | 'ImmersiveVideo' + } + 'chat:create': { + logContext: 'ProfileHeader' | 'NewChatDialog' | 'SendViaChatDialog' + } + 'chat:open': { + logContext: + | 'ProfileHeader' + | 'NewChatDialog' + | 'ChatsList' + | 'SendViaChatDialog' + } + 'starterPack:share': { + starterPack: string + shareType: 'link' | 'qrcode' + qrShareType?: 'save' | 'copy' | 'share' + } + 'starterPack:followAll': { + logContext: 'StarterPackProfilesList' | 'Onboarding' + starterPack: string + count: number + } + 'starterPack:delete': {} + 'starterPack:create': { + setName: boolean + setDescription: boolean + profilesCount: number + feedsCount: number + } + 'starterPack:ctaPress': { + starterPack: string + } + 'starterPack:opened': { + starterPack: string + } + 'link:clicked': { + url: string + domain: string + } + + 'feed:interstitial:feedCard:press': {} + + 'profile:header:suggestedFollowsCard:press': {} + + 'test:all:always': {} + 'test:all:sometimes': {} + 'test:all:boosted_by_gate1': {reason: 'base' | 'gate1'} + 'test:all:boosted_by_gate2': {reason: 'base' | 'gate2'} + 'test:all:boosted_by_both': {reason: 'base' | 'gate1' | 'gate2'} + 'test:gate1:always': {} + 'test:gate1:sometimes': {} + 'test:gate2:always': {} + 'test:gate2:sometimes': {} + + 'tmd:share': {} + 'tmd:download': {} + 'tmd:post': {} + + 'trendingTopics:show': { + context: 'settings' + } + 'trendingTopics:hide': { + context: 'settings' | 'sidebar' | 'interstitial' | 'explore:trending' + } + 'trendingTopic:click': { + context: 'sidebar' | 'interstitial' | 'explore' + } + 'recommendedTopic:click': { + context: 'explore' + } + 'trendingVideos:show': { + context: 'settings' + } + 'trendingVideos:hide': { + context: 'settings' | 'interstitial:discover' | 'interstitial:explore' + } + 'videoCard:click': { + context: 'interstitial:discover' | 'interstitial:explore' | 'feed' + } + + 'progressGuide:hide': {} + 'progressGuide:followDialog:open': {} +} diff --git a/src/logger/transports/bitdrift.ts b/src/logger/transports/bitdrift.ts index cf125c6e2..a407f9485 100644 --- a/src/logger/transports/bitdrift.ts +++ b/src/logger/transports/bitdrift.ts @@ -18,8 +18,7 @@ export const bitdriftTransport: Transport = ( ) => { const log = logFunctions[level] log(message.toString(), { - // match Sentry payload - context, + __context__: context, ...prepareMetadata(metadata), }) } diff --git a/src/logger/transports/sentry.ts b/src/logger/transports/sentry.ts index 890918d67..33dd78ec2 100644 --- a/src/logger/transports/sentry.ts +++ b/src/logger/transports/sentry.ts @@ -11,8 +11,7 @@ export const sentryTransport: Transport = ( timestamp, ) => { const meta = { - // match Bitdrift payload - context, + __context__: context, ...prepareMetadata(metadata), } let _tags = tags || {} diff --git a/src/logger/types.ts b/src/logger/types.ts index 517893d29..9110a8c6f 100644 --- a/src/logger/types.ts +++ b/src/logger/types.ts @@ -9,6 +9,12 @@ export enum LogContext { Notifications = 'notifications', ConversationAgent = 'conversation-agent', DMsAgent = 'dms-agent', + + /** + * METRIC IS FOR INTERNAL USE ONLY, don't create any other loggers using this + * context + */ + Metric = 'metric', } export enum LogLevel { @@ -33,9 +39,9 @@ export type Transport = ( */ export type Metadata = { /** - * Reserved for appending `LogContext` to logging payloads + * Reserved for appending `LogContext` in logging payloads */ - context?: undefined + __context__?: undefined /** * Applied as Sentry breadcrumb types. Defaults to `default`. -- cgit 1.4.1