From 6ca21eaebfbd01f69dff0c1db9dbe8a9c9f9ce60 Mon Sep 17 00:00:00 2001 From: Rhys Howell Date: Fri, 14 Feb 2025 13:14:32 -0500 Subject: [PATCH 1/5] chore(schema): add and update telemetry events --- .../export-schema-legacy-banner.tsx | 4 +- .../src/modules/schema-analysis.spec.ts | 268 +++++++++--------- .../src/modules/schema-analysis.ts | 193 ++++++++----- .../src/stores/schema-analysis-reducer.ts | 39 ++- .../src/stores/schema-export-reducer.ts | 155 ++++++---- packages/compass-schema/src/stores/store.ts | 6 +- .../compass-telemetry/src/telemetry-events.ts | 42 +++ 7 files changed, 427 insertions(+), 280 deletions(-) diff --git a/packages/compass-schema/src/components/export-schema-legacy-banner.tsx b/packages/compass-schema/src/components/export-schema-legacy-banner.tsx index f46e3ed3779..71415ac892b 100644 --- a/packages/compass-schema/src/components/export-schema-legacy-banner.tsx +++ b/packages/compass-schema/src/components/export-schema-legacy-banner.tsx @@ -13,7 +13,7 @@ import { import type { RootState, SchemaThunkDispatch } from '../stores/store'; import { - confirmedLegacySchemaShare, + confirmedExportLegacySchemaToClipboard, switchToSchemaExport, SchemaExportActions, stopShowingLegacyBanner, @@ -539,7 +539,7 @@ export default connect( }), (dispatch: SchemaThunkDispatch) => ({ onClose: () => dispatch({ type: SchemaExportActions.closeLegacyBanner }), - onLegacyShare: () => dispatch(confirmedLegacySchemaShare()), + onLegacyShare: () => dispatch(confirmedExportLegacySchemaToClipboard()), onSwitchToSchemaExport: () => dispatch(switchToSchemaExport()), stopShowingLegacyBanner: (choice: 'legacy' | 'export') => dispatch(stopShowingLegacyBanner(choice)), diff --git a/packages/compass-schema/src/modules/schema-analysis.spec.ts b/packages/compass-schema/src/modules/schema-analysis.spec.ts index b91ce702e1c..88940b8386e 100644 --- a/packages/compass-schema/src/modules/schema-analysis.spec.ts +++ b/packages/compass-schema/src/modules/schema-analysis.spec.ts @@ -6,11 +6,7 @@ import type { Schema } from 'mongodb-schema'; import { createNoopLogger } from '@mongodb-js/compass-logging/provider'; import { isInternalFieldPath } from 'hadron-document'; -import { - analyzeSchema, - calculateSchemaDepth, - schemaContainsGeoData, -} from './schema-analysis'; +import { analyzeSchema, calculateSchemaMetadata } from './schema-analysis'; const testDocs = [ { @@ -250,164 +246,176 @@ describe('schema-analysis', function () { }); }); - describe('#calculateSchemaDepth', function () { - describe('with an empty schema', function () { - let schema: Schema; - before(async function () { - schema = await mongoDBSchemaAnalyzeSchema([{}]); + describe('#calculateSchemaMetadata', function () { + describe('schema_depth', function () { + describe('with an empty schema', function () { + let schema: Schema; + before(async function () { + schema = await mongoDBSchemaAnalyzeSchema([{}]); + }); + + it('has a depth of 0', async function () { + const { schema_depth } = await calculateSchemaMetadata(schema); + expect(schema_depth).to.equal(0); + }); }); - it('has a depth of 0', function () { - expect(calculateSchemaDepth(schema)).to.equal(0); - }); - }); - - describe('with a basic schema', function () { - let schema: Schema; - before(async function () { - schema = await mongoDBSchemaAnalyzeSchema([ - { - someFields: { - pineapple: 25, + describe('with a basic schema', function () { + let schema: Schema; + before(async function () { + schema = await mongoDBSchemaAnalyzeSchema([ + { + someFields: { + pineapple: 25, + }, + ok: 'nice', }, - ok: 'nice', - }, - ]); - }); + ]); + }); - it('has a depth of 2', function () { - expect(calculateSchemaDepth(schema)).to.equal(2); + it('has a depth of 2', async function () { + const { schema_depth } = await calculateSchemaMetadata(schema); + expect(schema_depth).to.equal(2); + }); }); - }); - describe('with complex schema with different document depths', function () { - let schema: Schema; - before(async function () { - schema = await mongoDBSchemaAnalyzeSchema(testDocs); - }); + describe('with complex schema with different document depths', function () { + let schema: Schema; + before(async function () { + schema = await mongoDBSchemaAnalyzeSchema(testDocs); + }); - it('has the correct depth', function () { - expect(calculateSchemaDepth(schema)).to.equal(8); + it('has the correct depth', async function () { + const { schema_depth } = await calculateSchemaMetadata(schema); + expect(schema_depth).to.equal(8); + }); }); - }); - describe('with a basic array', function () { - let schema: Schema; - before(async function () { - schema = await mongoDBSchemaAnalyzeSchema([ - { - arrayField: [1, 2, 3], - }, - ]); - }); + describe('with a basic array', function () { + let schema: Schema; + before(async function () { + schema = await mongoDBSchemaAnalyzeSchema([ + { + arrayField: [1, 2, 3], + }, + ]); + }); - it('has a depth of two', function () { - expect(calculateSchemaDepth(schema)).to.equal(2); + it('has a depth of two', async function () { + const { schema_depth } = await calculateSchemaMetadata(schema); + expect(schema_depth).to.equal(2); + }); }); - }); - describe('with nested arrays', function () { - let schema: Schema; - before(async function () { - schema = await mongoDBSchemaAnalyzeSchema([ - { - arrayField: [[[['a']]]], - }, - ]); - }); + describe('with nested arrays', function () { + let schema: Schema; + before(async function () { + schema = await mongoDBSchemaAnalyzeSchema([ + { + arrayField: [[[['a']]]], + }, + ]); + }); - it('has the correct depth', function () { - expect(calculateSchemaDepth(schema)).to.equal(5); + it('has the correct depth', async function () { + const { schema_depth } = await calculateSchemaMetadata(schema); + expect(schema_depth).to.equal(5); + }); }); }); - }); - describe('#schemaContainsGeoData', function () { - describe('with an empty schema', function () { - let schema: Schema; - before(async function () { - schema = await mongoDBSchemaAnalyzeSchema([{}]); + describe('geo_data', function () { + describe('with an empty schema', function () { + let schema: Schema; + before(async function () { + schema = await mongoDBSchemaAnalyzeSchema([{}]); + }); + + it('returns false', async function () { + const { geo_data } = await calculateSchemaMetadata(schema); + expect(geo_data).to.equal(false); + }); }); - it('returns false', function () { - expect(schemaContainsGeoData(schema)).to.equal(false); - }); - }); - - describe('with a basic document without geo data', function () { - let schema: Schema; - before(async function () { - schema = await mongoDBSchemaAnalyzeSchema([ - { - fruits: { - pineapple: 'yes', - apples: ['golden', 'fiji'], + describe('with a basic document without geo data', function () { + let schema: Schema; + before(async function () { + schema = await mongoDBSchemaAnalyzeSchema([ + { + fruits: { + pineapple: 'yes', + apples: ['golden', 'fiji'], + }, }, - }, - ]); - }); + ]); + }); - it('does not detect geo data', function () { - expect(schemaContainsGeoData(schema)).to.equal(false); + it('does not detect geo data', async function () { + const { geo_data } = await calculateSchemaMetadata(schema); + expect(geo_data).to.equal(false); + }); }); - }); - describe('with more complex documents without geo data', function () { - let schema: Schema; - before(async function () { - schema = await mongoDBSchemaAnalyzeSchema(testDocs); - }); + describe('with more complex documents without geo data', function () { + let schema: Schema; + before(async function () { + schema = await mongoDBSchemaAnalyzeSchema(testDocs); + }); - it('does not detect geo data', function () { - expect(schemaContainsGeoData(schema)).to.equal(false); + it('does not detect geo data', async function () { + const { geo_data } = await calculateSchemaMetadata(schema); + expect(geo_data).to.equal(false); + }); }); - }); - describe('with a basic document with Point geo data', function () { - let schema: Schema; - before(async function () { - schema = await mongoDBSchemaAnalyzeSchema([ - { - name: 'somewhere', - location: { - type: 'Point', - coordinates: [-73.856077, 40.848447], + describe('with a basic document with Point geo data', function () { + let schema: Schema; + before(async function () { + schema = await mongoDBSchemaAnalyzeSchema([ + { + name: 'somewhere', + location: { + type: 'Point', + coordinates: [-73.856077, 40.848447], + }, }, - }, - ]); - }); + ]); + }); - it('detects geo data', function () { - expect(schemaContainsGeoData(schema)).to.equal(true); + it('detects geo data', async function () { + const { geo_data } = await calculateSchemaMetadata(schema); + expect(geo_data).to.equal(true); + }); }); - }); - describe('with Polygon geo data', function () { - let schema: Schema; - before(async function () { - schema = await mongoDBSchemaAnalyzeSchema([ - { - type: 'geojson', - data: { - type: 'Feature', - geometry: { - type: 'Polygon', - coordinates: [ - [ - [-73.856077, 40.848447], - [-72.856077, 41.848447], - [-73.856077, 41.848447], - [-72.856077, 40.848447], + describe('with Polygon geo data', function () { + let schema: Schema; + before(async function () { + schema = await mongoDBSchemaAnalyzeSchema([ + { + type: 'geojson', + data: { + type: 'Feature', + geometry: { + type: 'Polygon', + coordinates: [ + [ + [-73.856077, 40.848447], + [-72.856077, 41.848447], + [-73.856077, 41.848447], + [-72.856077, 40.848447], + ], ], - ], + }, }, }, - }, - ]); - }); + ]); + }); - it('detects geo data', function () { - expect(schemaContainsGeoData(schema)).to.equal(true); + it('detects geo data', async function () { + const { geo_data } = await calculateSchemaMetadata(schema); + expect(geo_data).to.equal(true); + }); }); }); }); diff --git a/packages/compass-schema/src/modules/schema-analysis.ts b/packages/compass-schema/src/modules/schema-analysis.ts index 0774f9a54b8..288807c5605 100644 --- a/packages/compass-schema/src/modules/schema-analysis.ts +++ b/packages/compass-schema/src/modules/schema-analysis.ts @@ -11,16 +11,6 @@ import type { import type { DataService } from '../stores/store'; import type { Logger } from '@mongodb-js/compass-logging'; -const MONGODB_GEO_TYPES = [ - 'Point', - 'LineString', - 'Polygon', - 'MultiPoint', - 'MultiLineString', - 'MultiPolygon', - 'GeometryCollection', -]; - // hack for driver 3.6 not promoting error codes and // attributes from ejson when promoteValue is false. function promoteMongoErrorCode(err?: Error & { code?: unknown }) { @@ -92,76 +82,135 @@ export const analyzeSchema = async ( } }; -function _calculateSchemaFieldDepth( - fieldsOrTypes: SchemaField[] | SchemaType[] -): number { - if (!fieldsOrTypes || fieldsOrTypes.length === 0) { - return 0; - } +function isSchemaType( + fieldOrType: SchemaField | SchemaType +): fieldOrType is SchemaType { + return (fieldOrType as SchemaType).bsonType !== undefined; +} - let deepestPath = 1; - for (const fieldOrType of fieldsOrTypes) { - if ((fieldOrType as DocumentSchemaType).bsonType === 'Document') { - const deepestFieldPath = - _calculateSchemaFieldDepth((fieldOrType as DocumentSchemaType).fields) + - 1; /* Increment by one when we go a level deeper. */ - - deepestPath = Math.max(deepestFieldPath, deepestPath); - } else if ( - (fieldOrType as ArraySchemaType).bsonType === 'Array' || - (fieldOrType as SchemaField).types - ) { - // Increment by one when we go a level deeper. - const increment = - (fieldOrType as ArraySchemaType).bsonType === 'Array' ? 1 : 0; - const deepestFieldPath = - _calculateSchemaFieldDepth( - (fieldOrType as ArraySchemaType | SchemaField).types - ) + increment; - - deepestPath = Math.max(deepestFieldPath, deepestPath); - } - } +const MONGODB_GEO_TYPES = [ + 'Point', + 'LineString', + 'Polygon', + 'MultiPoint', + 'MultiLineString', + 'MultiPolygon', + 'GeometryCollection', +]; - return deepestPath; -} +// Every 1000 iterations, unblock the thread. +const UNBLOCK_INTERVAL_COUNT = 1000; +const unblockThread = async () => + new Promise((resolve) => setTimeout(resolve)); + +export async function calculateSchemaMetadata(schema: Schema): Promise<{ + /** + * Key/value pairs of bsonType and count. + */ + field_types: { + [bsonType: string]: number; + }; + + /** + * The count of fields with multiple types in a given schema (not counting undefined). + */ + variable_type_count: number; + + /** + * The count of fields that don't appear on all documents. + */ + optional_field_count: number; + + /** + * The number of nested levels. + */ + schema_depth: number; + + /** + * Indicates whether the schema contains geospatial data. + */ + geo_data: boolean; +}> { + let hasGeoData = false; + const fieldTypes: { + [bsonType: string]: number; + } = {}; + let variableTypeCount = 0; + const optionalFieldCount = 0; + let unblockThreadCounter = 0; + + async function traverseSchemaTree( + fieldsOrTypes: SchemaField[] | SchemaType[] + ): Promise { + unblockThreadCounter++; + if (unblockThreadCounter === UNBLOCK_INTERVAL_COUNT) { + unblockThreadCounter = 0; + await unblockThread(); + } -export function calculateSchemaDepth(schema: Schema): number { - const schemaDepth = _calculateSchemaFieldDepth(schema.fields); - return schemaDepth; -} + if (!fieldsOrTypes || fieldsOrTypes.length === 0) { + return 0; + } -function _containsGeoData( - fieldsOrTypes: SchemaField[] | SchemaType[] -): boolean { - if (!fieldsOrTypes) { - return false; - } + let deepestPath = 1; - for (const fieldOrType of fieldsOrTypes) { - if ( - fieldOrType.path[fieldOrType.path.length - 1] === 'type' && - (fieldOrType as PrimitiveSchemaType).values && - MONGODB_GEO_TYPES.find((geoType) => - (fieldOrType as PrimitiveSchemaType).values?.find( - (value) => value === geoType + for (const fieldOrType of fieldsOrTypes) { + if ( + fieldOrType.path[fieldOrType.path.length - 1] === 'type' && + (fieldOrType as PrimitiveSchemaType).values && + MONGODB_GEO_TYPES.find((geoType) => + (fieldOrType as PrimitiveSchemaType).values?.find( + (value) => value === geoType + ) ) - ) - ) { - return true; - } + ) { + hasGeoData = true; + } - const hasGeoData = _containsGeoData( - (fieldOrType as ArraySchemaType | SchemaField).types ?? - (fieldOrType as DocumentSchemaType).fields - ); - if (hasGeoData) { - return true; + if (isSchemaType(fieldOrType)) { + fieldTypes[fieldOrType.bsonType] = + (fieldTypes[fieldOrType.bsonType] || 0) + 1; + } else { + if (fieldOrType.type !== 'string') { + variableTypeCount++; + } + } + + if ((fieldOrType as DocumentSchemaType).bsonType === 'Document') { + const deepestFieldPath = + (await traverseSchemaTree( + (fieldOrType as DocumentSchemaType).fields + )) + 1; /* Increment by one when we go a level deeper. */ + + deepestPath = Math.max(deepestFieldPath, deepestPath); + } else if ( + (fieldOrType as ArraySchemaType).bsonType === 'Array' || + (fieldOrType as SchemaField).types + ) { + // Increment by one when we go a level deeper. + const increment = + (fieldOrType as ArraySchemaType).bsonType === 'Array' ? 1 : 0; + const deepestFieldPath = + (await traverseSchemaTree( + (fieldOrType as ArraySchemaType | SchemaField).types + )) + increment; + + deepestPath = Math.max(deepestFieldPath, deepestPath); + } } + + return deepestPath; } - return false; -} -export function schemaContainsGeoData(schema: Schema): boolean { - return _containsGeoData(schema.fields); + const schemaDepth = await traverseSchemaTree(schema.fields); + + return { + field_types: fieldTypes, + geo_data: hasGeoData, + // TODO: calculate these + optional_field_count: optionalFieldCount, + + schema_depth: schemaDepth, + variable_type_count: variableTypeCount, + }; } diff --git a/packages/compass-schema/src/stores/schema-analysis-reducer.ts b/packages/compass-schema/src/stores/schema-analysis-reducer.ts index c6a5648d4f5..24229384992 100644 --- a/packages/compass-schema/src/stores/schema-analysis-reducer.ts +++ b/packages/compass-schema/src/stores/schema-analysis-reducer.ts @@ -13,9 +13,8 @@ import { import { addLayer, generateGeoQuery } from '../modules/geo'; import { analyzeSchema, - calculateSchemaDepth, + calculateSchemaMetadata, type SchemaAccessor, - schemaContainsGeoData, } from '../modules/schema-analysis'; import { capMaxTimeMSAtPreferenceLimit } from 'compass-preferences-model/provider'; import type { Circle, Layer, LayerGroup, Polygon } from 'leaflet'; @@ -257,14 +256,34 @@ export const startAnalysis = (): SchemaThunkAction< schemaAccessor, }); - // track schema analyzed - const trackEvent = () => ({ - with_filter: Object.entries(query.filter ?? {}).length > 0, - schema_width: schema?.fields?.length ?? 0, - schema_depth: schema ? calculateSchemaDepth(schema) : 0, - geo_data: schema ? schemaContainsGeoData(schema) : false, - analysis_time_ms: analysisTime, - }); + const trackEvent = async () => { + const { + field_types, + geo_data, + optional_field_count, + schema_depth, + variable_type_count, + } = schema + ? await calculateSchemaMetadata(schema) + : { + field_types: {}, + geo_data: false, + optional_field_count: 0, + schema_depth: 0, + variable_type_count: 0, + }; + + return { + with_filter: Object.entries(query.filter ?? {}).length > 0, + schema_width: schema?.fields?.length ?? 0, + field_types, + variable_type_count, + optional_field_count, + schema_depth, + geo_data, + analysis_time_ms: analysisTime, + }; + }; track('Schema Analyzed', trackEvent, connectionInfoRef.current); geoLayersRef.current = {}; diff --git a/packages/compass-schema/src/stores/schema-export-reducer.ts b/packages/compass-schema/src/stores/schema-export-reducer.ts index ddb1e6c925f..59a7445e2d1 100644 --- a/packages/compass-schema/src/stores/schema-export-reducer.ts +++ b/packages/compass-schema/src/stores/schema-export-reducer.ts @@ -6,15 +6,14 @@ import type { MongoDBJSONSchema, ExpandedJSONSchema, } from 'mongodb-schema'; +import { openToast } from '@mongodb-js/compass-components'; import type { SchemaThunkAction } from './store'; import { isAction } from '../utils'; import { - calculateSchemaDepth, - schemaContainsGeoData, + calculateSchemaMetadata, type SchemaAccessor, } from '../modules/schema-analysis'; -import { openToast } from '@mongodb-js/compass-components'; export type SchemaFormat = | 'standardJSON' @@ -161,6 +160,41 @@ async function getSchemaByFormat({ return JSON.stringify(schema, null, 2); } +const _trackSchemaExported = ({ + schema, + source, + format, +}: { + schema: InternalSchema | null; + source: 'app_menu' | 'schema_tab'; + format: SchemaFormat; +}): SchemaThunkAction => { + return (dispatch, getState, { track, connectionInfoRef }) => { + // Use a function here to a) ensure that the calculations here + // are only made when telemetry is enabled and b) that errors from + // those calculations are caught and logged rather than displayed to + // users as errors from the core schema sharing logic. + const trackEvent = async () => { + const { geo_data, schema_depth } = schema + ? await calculateSchemaMetadata(schema) + : { + geo_data: false, + schema_depth: 0, + }; + + return { + has_schema: schema !== null, + format, + source, + schema_width: schema?.fields?.length ?? 0, + schema_depth, + geo_data, + }; + }; + track('Schema Exported', trackEvent, connectionInfoRef.current); + }; +}; + export const changeExportSchemaFormat = ( exportFormat: SchemaFormat ): SchemaThunkAction< @@ -185,18 +219,19 @@ export const changeExportSchemaFormat = ( exportFormat, }); + log.info( + mongoLogId(1_001_000_342), + 'Schema export formatting', + 'Formatting schema', + { + format: exportFormat, + } + ); + + const { schemaAccessor, schema } = getState().schemaAnalysis; + let exportedSchema: string; try { - log.info( - mongoLogId(1_001_000_342), - 'Schema export formatting', - 'Formatting schema', - { - format: exportFormat, - } - ); - - const schemaAccessor = getState().schemaAnalysis.schemaAccessor; if (!schemaAccessor) { throw new Error('No schema analysis available'); } @@ -230,6 +265,14 @@ export const changeExportSchemaFormat = ( return; } + dispatch( + _trackSchemaExported({ + schema, + format: exportFormat, + source: 'schema_tab', + }) + ); + log.info( mongoLogId(1_001_000_344), 'Schema export formatting complete', @@ -385,7 +428,7 @@ export const openLegacyBanner = (): SchemaThunkAction => { }); } if (savedChoice === 'legacy') { - dispatch(confirmedLegacySchemaShare()); + dispatch(confirmedExportLegacySchemaToClipboard()); return; } if (savedChoice === 'export') { @@ -415,56 +458,42 @@ export const switchToSchemaExport = (): SchemaThunkAction => { }; }; -export const confirmedLegacySchemaShare = (): SchemaThunkAction => { - return (dispatch, getState, { namespace }) => { - const { - schemaAnalysis: { schema }, - } = getState(); - const hasSchema = schema !== null; - if (hasSchema) { - void navigator.clipboard.writeText(JSON.stringify(schema, null, ' ')); - } - dispatch(_trackSchemaShared(hasSchema)); - dispatch({ type: SchemaExportActions.closeLegacyBanner }); - openToast( - 'share-schema', - hasSchema - ? { - variant: 'success', - title: 'Schema Copied', - description: `The schema definition of ${namespace} has been copied to your clipboard in JSON format.`, - timeout: 5_000, - } - : { - variant: 'warning', - title: 'Analyze Schema First', - description: - 'Please analyze the schema in the schema tab before sharing the schema.', - } - ); - }; -}; - -export const _trackSchemaShared = ( - hasSchema: boolean -): SchemaThunkAction => { - return (dispatch, getState, { track, connectionInfoRef }) => { - const { - schemaAnalysis: { schema }, - } = getState(); - // Use a function here to a) ensure that the calculations here - // are only made when telemetry is enabled and b) that errors from - // those calculations are caught and logged rather than displayed to - // users as errors from the core schema sharing logic. - const trackEvent = () => ({ - has_schema: hasSchema, - schema_width: schema?.fields?.length ?? 0, - schema_depth: schema ? calculateSchemaDepth(schema) : 0, - geo_data: schema ? schemaContainsGeoData(schema) : false, - }); - track('Schema Exported', trackEvent, connectionInfoRef.current); +export const confirmedExportLegacySchemaToClipboard = + (): SchemaThunkAction => { + return (dispatch, getState, { namespace }) => { + const { + schemaAnalysis: { schema }, + } = getState(); + const hasSchema = schema !== null; + if (hasSchema) { + void navigator.clipboard.writeText(JSON.stringify(schema, null, ' ')); + } + dispatch( + _trackSchemaExported({ + schema, + source: 'app_menu', + format: 'legacyJSON', + }) + ); + dispatch({ type: SchemaExportActions.closeLegacyBanner }); + openToast( + 'share-schema', + hasSchema + ? { + variant: 'success', + title: 'Schema Copied', + description: `The schema definition of ${namespace} has been copied to your clipboard in JSON format.`, + timeout: 5_000, + } + : { + variant: 'warning', + title: 'Analyze Schema First', + description: + 'Please analyze the schema in the schema tab before sharing the schema.', + } + ); + }; }; -}; export const stopShowingLegacyBanner = ( choice: 'legacy' | 'export' diff --git a/packages/compass-schema/src/stores/store.ts b/packages/compass-schema/src/stores/store.ts index 2a7bcf4bddc..a7c32d1a663 100644 --- a/packages/compass-schema/src/stores/store.ts +++ b/packages/compass-schema/src/stores/store.ts @@ -20,7 +20,7 @@ import type { TrackFunction } from '@mongodb-js/compass-telemetry'; import { schemaAnalysisReducer, stopAnalysis } from './schema-analysis-reducer'; import { cancelExportSchema, - confirmedLegacySchemaShare, + confirmedExportLegacySchemaToClipboard, openLegacyBanner, schemaExportReducer, } from './schema-export-reducer'; @@ -72,17 +72,17 @@ export function activateSchemaPlugin( { on, cleanup, addCleanup }: ActivateHelpers ) { const store = configureStore(services, namespace); + /** * When `Share Schema as JSON` clicked in menu show a dialog message. */ - on(services.localAppRegistry, 'menu-share-schema-json', () => { const { enableExportSchema } = services.preferences.getPreferences(); if (enableExportSchema) { store.dispatch(openLegacyBanner()); return; } - store.dispatch(confirmedLegacySchemaShare()); + store.dispatch(confirmedExportLegacySchemaToClipboard()); }); addCleanup(() => store.dispatch(stopAnalysis())); diff --git a/packages/compass-telemetry/src/telemetry-events.ts b/packages/compass-telemetry/src/telemetry-events.ts index 22df5855633..520d843c533 100644 --- a/packages/compass-telemetry/src/telemetry-events.ts +++ b/packages/compass-telemetry/src/telemetry-events.ts @@ -1934,6 +1934,23 @@ type SchemaAnalyzedEvent = ConnectionScopedEvent<{ */ schema_width: number; + /** + * Key/value pairs of bsonType and count. + */ + field_types: { + [bsonType: string]: number; + }; + + /** + * The count of fields with multiple types in a given schema (not counting undefined). + */ + variable_type_count: number; + + /** + * The count of fields that don't appear on all documents. + */ + optional_field_count: number; + /** * The number of nested levels. */ @@ -1951,6 +1968,26 @@ type SchemaAnalyzedEvent = ConnectionScopedEvent<{ }; }>; +/** + * This event is fired when user analyzes the schema. + * + * @category Schema + */ +type SchemaAnalysisCancelledEvent = ConnectionScopedEvent<{ + name: 'Schema Analysis Cancelled'; + payload: { + /** + * Indicates whether a filter was applied during the schema analysis. + */ + with_filter: boolean; + + /** + * The time taken when analyzing the schema, before being cancelled, in milliseconds. + */ + analysis_time_ms: number; + }; +}>; + /** * This event is fired when user shares the schema. * @@ -1964,6 +2001,10 @@ type SchemaExportedEvent = ConnectionScopedEvent<{ */ has_schema: boolean; + format: 'standardJSON' | 'mongoDBJSON' | 'extendedJSON' | 'legacyJSON'; + + source: 'app_menu' | 'schema_tab'; + /** * The number of fields at the top level. */ @@ -2683,6 +2724,7 @@ export type TelemetryEvent = | QueryHistoryRecentEvent | QueryHistoryRecentUsedEvent | QueryResultsRefreshedEvent + | SchemaAnalysisCancelledEvent | SchemaAnalyzedEvent | SchemaExportedEvent | SchemaValidationAddedEvent From 94716adfe4e5cad43bc2813f677b8bd85871ea5f Mon Sep 17 00:00:00 2001 From: Rhys Howell Date: Fri, 14 Feb 2025 14:11:54 -0500 Subject: [PATCH 2/5] fixup: add tests, add descriptive note to telemetry fields for when they occur --- .../src/modules/schema-analysis.spec.ts | 223 ++++++++++++++++++ .../src/modules/schema-analysis.ts | 27 ++- .../compass-telemetry/src/telemetry-events.ts | 2 + 3 files changed, 246 insertions(+), 6 deletions(-) diff --git a/packages/compass-schema/src/modules/schema-analysis.spec.ts b/packages/compass-schema/src/modules/schema-analysis.spec.ts index 88940b8386e..70708b0fffd 100644 --- a/packages/compass-schema/src/modules/schema-analysis.spec.ts +++ b/packages/compass-schema/src/modules/schema-analysis.spec.ts @@ -418,5 +418,228 @@ describe('schema-analysis', function () { }); }); }); + + describe('variable_type_count', function () { + describe('with fields having multiple types', function () { + let schema: Schema; + before(async function () { + schema = await mongoDBSchemaAnalyzeSchema([ + { pineapple: 123 }, + { pineapple: 'string' }, + { pineapple: true }, + { singleType: 'onlyString' }, + ]); + }); + + it('counts fields with more than one type', async function () { + const { variable_type_count } = await calculateSchemaMetadata(schema); + expect(variable_type_count).to.equal(1); + }); + }); + + describe('with no fields', function () { + let schema: Schema; + before(async function () { + schema = await mongoDBSchemaAnalyzeSchema([{}]); + }); + + it('returns zero', async function () { + const { variable_type_count } = await calculateSchemaMetadata(schema); + expect(variable_type_count).to.equal(0); + }); + }); + + describe('with all single-type fields', function () { + let schema: Schema; + before(async function () { + schema = await mongoDBSchemaAnalyzeSchema([ + { a: 1 }, + { a: 2, b: 2 }, + { a: 2, c: 3 }, + ]); + }); + + it('returns zero', async function () { + const { variable_type_count } = await calculateSchemaMetadata(schema); + expect(variable_type_count).to.equal(0); + }); + }); + + describe('with differing nested fields fields', function () { + let schema: Schema; + before(async function () { + schema = await mongoDBSchemaAnalyzeSchema([ + { required: { notAlwaysPresent: 'yes' } }, + { required: { notAlwaysPresent: 123456 } }, + { required: { sometimesHere: true } }, + ]); + }); + + it('returns does not count the optionals', async function () { + const { variable_type_count } = await calculateSchemaMetadata(schema); + expect(variable_type_count).to.equal(0); + }); + }); + }); + + describe('optional_field_count', function () { + describe('with optional fields', function () { + let schema: Schema; + before(async function () { + schema = await mongoDBSchemaAnalyzeSchema([ + { pineapple: 'yes', optional: 123 }, + { pineapple: 'yes' }, + { pineapple: 'yes', optional: 'maybe' }, + ]); + }); + + it('counts fields missing from some documents', async function () { + const { optional_field_count } = await calculateSchemaMetadata( + schema + ); + expect(optional_field_count).to.equal(1); + }); + }); + + describe('with no fields', function () { + let schema: Schema; + before(async function () { + schema = await mongoDBSchemaAnalyzeSchema([{}]); + }); + + it('returns zero', async function () { + const { optional_field_count } = await calculateSchemaMetadata( + schema + ); + expect(optional_field_count).to.equal(0); + }); + }); + + describe('with all required fields', function () { + let schema: Schema; + before(async function () { + schema = await mongoDBSchemaAnalyzeSchema([ + { required: 1 }, + { required: 2 }, + { required: 3 }, + ]); + }); + + it('returns zero', async function () { + const { optional_field_count } = await calculateSchemaMetadata( + schema + ); + expect(optional_field_count).to.equal(0); + }); + }); + + describe('with differing nested fields fields', function () { + let schema: Schema; + before(async function () { + schema = await mongoDBSchemaAnalyzeSchema([ + { required: { notAlwaysPresent: true } }, + { required: { notAlwaysPresent: true } }, + { required: { sometimesHere: true } }, + ]); + }); + + it('returns does not count the optionals', async function () { + const { optional_field_count } = await calculateSchemaMetadata( + schema + ); + expect(optional_field_count).to.equal(0); + }); + }); + }); + + describe('field_types', function () { + describe('with mixed bson types', function () { + let schema: Schema; + before(async function () { + schema = await mongoDBSchemaAnalyzeSchema([ + { number: 42, value: true, string: 'test' }, + { + number: 100, + value: 'ok', + anotherString: 'hello', + yetAnotherString: 'blueberry', + }, + ]); + }); + + it('correctly counts the bson types', async function () { + const { field_types } = await calculateSchemaMetadata(schema); + expect(field_types).to.deep.equal({ + Number: 1, + Boolean: 1, + String: 4, + Undefined: 3, + }); + }); + }); + + describe('with no fields', function () { + let schema: Schema; + before(async function () { + schema = await mongoDBSchemaAnalyzeSchema([{}]); + }); + + it('returns an empty object', async function () { + const { field_types } = await calculateSchemaMetadata(schema); + expect(field_types).to.deep.equal({}); + }); + }); + + describe('with a type occurring multiple times', function () { + let schema: Schema; + before(async function () { + schema = await mongoDBSchemaAnalyzeSchema([ + { pineapple: 1, secondPineapple: 2, singlePineapple: 3 }, + { pineapple: 1, secondPineapple: 2 }, + { pineapple: 2, secondPineapple: 2 }, + ]); + }); + + it('correctly counts a single type', async function () { + const { field_types } = await calculateSchemaMetadata(schema); + expect(field_types).to.deep.equal({ + Number: 3, + Undefined: 1, + }); + }); + }); + + describe('with nested arrays and objects', function () { + let schema: Schema; + before(async function () { + schema = await mongoDBSchemaAnalyzeSchema([ + { + arrayField: [[[['a']]]], + }, + { + arrayField: [[[['a', 123]]]], + objectField: { + innerObject: { + pineapple: 'wahoo', + anotherNumberInObject: 55, + }, + numberInObject: 42, + }, + }, + ]); + }); + + it('correctly counts bson types', async function () { + const { field_types } = await calculateSchemaMetadata(schema); + expect(field_types).to.deep.equal({ + Document: 2, + Array: 4, + String: 2, + Number: 3, + Undefined: 1, + }); + }); + }); + }); }); }); diff --git a/packages/compass-schema/src/modules/schema-analysis.ts b/packages/compass-schema/src/modules/schema-analysis.ts index 288807c5605..c99cd733c89 100644 --- a/packages/compass-schema/src/modules/schema-analysis.ts +++ b/packages/compass-schema/src/modules/schema-analysis.ts @@ -113,11 +113,13 @@ export async function calculateSchemaMetadata(schema: Schema): Promise<{ /** * The count of fields with multiple types in a given schema (not counting undefined). + * This is only calculated for the top level fields, not nested fields and arrays. */ variable_type_count: number; /** * The count of fields that don't appear on all documents. + * This is only calculated for the top level fields, not nested fields and arrays. */ optional_field_count: number; @@ -136,11 +138,12 @@ export async function calculateSchemaMetadata(schema: Schema): Promise<{ [bsonType: string]: number; } = {}; let variableTypeCount = 0; - const optionalFieldCount = 0; + let optionalFieldCount = 0; let unblockThreadCounter = 0; async function traverseSchemaTree( - fieldsOrTypes: SchemaField[] | SchemaType[] + fieldsOrTypes: SchemaField[] | SchemaType[], + isRoot = false ): Promise { unblockThreadCounter++; if (unblockThreadCounter === UNBLOCK_INTERVAL_COUNT) { @@ -169,11 +172,20 @@ export async function calculateSchemaMetadata(schema: Schema): Promise<{ if (isSchemaType(fieldOrType)) { fieldTypes[fieldOrType.bsonType] = - (fieldTypes[fieldOrType.bsonType] || 0) + 1; - } else { - if (fieldOrType.type !== 'string') { + (fieldTypes[fieldOrType.bsonType] ?? 0) + 1; + } else if (isRoot) { + // Count variable types (more than one unique type excluding undefined). + if ( + fieldOrType.types && + (fieldOrType.types.length > 2 || + fieldOrType.types?.filter((t) => t.name !== 'Undefined').length > 1) + ) { variableTypeCount++; } + + if (fieldOrType.probability < 1) { + optionalFieldCount++; + } } if ((fieldOrType as DocumentSchemaType).bsonType === 'Document') { @@ -202,7 +214,10 @@ export async function calculateSchemaMetadata(schema: Schema): Promise<{ return deepestPath; } - const schemaDepth = await traverseSchemaTree(schema.fields); + const schemaDepth = await traverseSchemaTree( + schema.fields, + true /* isRoot */ + ); return { field_types: fieldTypes, diff --git a/packages/compass-telemetry/src/telemetry-events.ts b/packages/compass-telemetry/src/telemetry-events.ts index 520d843c533..4fde6b051f9 100644 --- a/packages/compass-telemetry/src/telemetry-events.ts +++ b/packages/compass-telemetry/src/telemetry-events.ts @@ -1943,11 +1943,13 @@ type SchemaAnalyzedEvent = ConnectionScopedEvent<{ /** * The count of fields with multiple types in a given schema (not counting undefined). + * This is only calculated for the top level fields, not nested fields and arrays. */ variable_type_count: number; /** * The count of fields that don't appear on all documents. + * This is only calculated for the top level fields, not nested fields and arrays. */ optional_field_count: number; From 3a03e91ff0de48775c47a124520940e2e70829aa Mon Sep 17 00:00:00 2001 From: Rhys Howell Date: Fri, 14 Feb 2025 14:18:21 -0500 Subject: [PATCH 3/5] fixup: only track schema export when copied or the download button is clicked --- .../src/components/export-schema-modal.tsx | 7 ++++- .../src/stores/schema-export-reducer.ts | 26 ++++++++++++------- 2 files changed, 23 insertions(+), 10 deletions(-) diff --git a/packages/compass-schema/src/components/export-schema-modal.tsx b/packages/compass-schema/src/components/export-schema-modal.tsx index 1148a0e8435..08768eea7fb 100644 --- a/packages/compass-schema/src/components/export-schema-modal.tsx +++ b/packages/compass-schema/src/components/export-schema-modal.tsx @@ -21,6 +21,7 @@ import { cancelExportSchema, changeExportSchemaFormat, closeExportSchema, + trackSchemaExported, type SchemaFormat, type ExportStatus, } from '../stores/schema-export-reducer'; @@ -75,6 +76,7 @@ const ExportSchemaModal: React.FunctionComponent<{ onCancelSchemaExport: () => void; onChangeSchemaExportFormat: (format: SchemaFormat) => Promise; onClose: () => void; + onExportedSchemaCopied: () => void; }> = ({ errorMessage, exportStatus, @@ -84,6 +86,7 @@ const ExportSchemaModal: React.FunctionComponent<{ onCancelSchemaExport, onChangeSchemaExportFormat, onClose, + onExportedSchemaCopied, }) => { const onFormatOptionSelected = useCallback( (event: ChangeEvent) => { @@ -143,6 +146,7 @@ const ExportSchemaModal: React.FunctionComponent<{ language="json" className={codeStyles} copyable={true} + onCopy={onExportedSchemaCopied} > {exportedSchema ?? 'Empty'} @@ -163,7 +167,7 @@ const ExportSchemaModal: React.FunctionComponent<{