diff --git a/src/Common/dataAccess/readCollections.ts b/src/Common/dataAccess/readCollections.ts index 1cc477692..d90832219 100644 --- a/src/Common/dataAccess/readCollections.ts +++ b/src/Common/dataAccess/readCollections.ts @@ -5,6 +5,8 @@ import { CosmosDbArtifactType } from "Contracts/FabricMessagesContract"; import { isFabric, isFabricMirroredKey } from "Platform/Fabric/FabricUtil"; import { AuthType } from "../../AuthType"; import * as DataModels from "../../Contracts/DataModels"; +import { Action } from "../../Shared/Telemetry/TelemetryConstants"; +import { traceFailure, traceStart, traceSuccess } from "../../Shared/Telemetry/TelemetryProcessor"; import { FabricArtifactInfo, userContext } from "../../UserContext"; import { logConsoleProgress } from "../../Utils/NotificationConsoleUtils"; import { listCassandraTables } from "../../Utils/arm/generatedClients/cosmos/cassandraResources"; @@ -17,6 +19,11 @@ import { handleError } from "../ErrorHandlingUtils"; export async function readCollections(databaseId: string): Promise { const clearMessage = logConsoleProgress(`Querying containers for database ${databaseId}`); + const startKey = traceStart(Action.ReadCollections, { + dataExplorerArea: "ResourceTree", + databaseId, + apiType: userContext.apiType, + }); if (isFabricMirroredKey() && userContext.fabricContext?.databaseName === databaseId) { const collections: DataModels.Collection[] = []; @@ -43,8 +50,10 @@ export async function readCollections(databaseId: string): Promise a.id.localeCompare(b.id)); + traceSuccess(Action.ReadCollections, { databaseId, collectionCount: collections.length }, startKey); return collections; } catch (error) { + traceFailure(Action.ReadCollections, { databaseId, error: error?.message }, startKey); handleError(error, "ReadCollections", `Error while querying containers for database ${databaseId}`); throw error; } finally { @@ -59,7 +68,9 @@ export async function readCollections(databaseId: string): Promise { const clearMessage = logConsoleProgress(`Querying containers for database ${databaseId}`); + const startKey = traceStart(Action.ReadCollections, { + dataExplorerArea: "ResourceTree", + databaseId, + paginated: true, + }); try { const sdkResponse = await client() .database(databaseId) @@ -99,8 +121,14 @@ export async function readCollectionsWithPagination( collections: sdkResponse.resources as DataModels.Collection[], continuationToken: sdkResponse.continuationToken, }; + traceSuccess( + Action.ReadCollections, + { databaseId, collectionCount: collectionsWithPagination.collections?.length, paginated: true }, + startKey, + ); return collectionsWithPagination; } catch (error) { + traceFailure(Action.ReadCollections, { databaseId, error: error?.message, paginated: true }, startKey); handleError(error, "ReadCollections", `Error while querying containers for database ${databaseId}`); throw error; } finally { diff --git a/src/Common/dataAccess/readDatabases.ts b/src/Common/dataAccess/readDatabases.ts index 66ea1e76f..85b1c3186 100644 --- a/src/Common/dataAccess/readDatabases.ts +++ b/src/Common/dataAccess/readDatabases.ts @@ -2,6 +2,8 @@ import { CosmosDbArtifactType } from "Contracts/FabricMessagesContract"; import { isFabric, isFabricMirroredKey, isFabricNative } from "Platform/Fabric/FabricUtil"; import { AuthType } from "../../AuthType"; import * as DataModels from "../../Contracts/DataModels"; +import { Action } from "../../Shared/Telemetry/TelemetryConstants"; +import { traceFailure, traceStart, traceSuccess } from "../../Shared/Telemetry/TelemetryProcessor"; import { FabricArtifactInfo, userContext } from "../../UserContext"; import { logConsoleProgress } from "../../Utils/NotificationConsoleUtils"; import { listCassandraKeyspaces } from "../../Utils/arm/generatedClients/cosmos/cassandraResources"; @@ -14,6 +16,10 @@ import { handleError } from "../ErrorHandlingUtils"; export async function readDatabases(): Promise { let databases: DataModels.Database[]; const clearMessage = logConsoleProgress(`Querying databases`); + const startKey = traceStart(Action.ReadDatabases, { + dataExplorerArea: "ResourceTree", + apiType: userContext.apiType, + }); if ( isFabricMirroredKey() && @@ -81,9 +87,11 @@ export async function readDatabases(): Promise { databases = sdkResponse.resources as DataModels.Database[]; } } catch (error) { + traceFailure(Action.ReadDatabases, { error: error?.message }, startKey); handleError(error, "ReadDatabases", `Error while querying databases`); throw error; } + traceSuccess(Action.ReadDatabases, { databaseCount: databases?.length }, startKey); clearMessage(); return databases; } diff --git a/src/Explorer/Explorer.tsx b/src/Explorer/Explorer.tsx index cf4aa01ea..5643b94e9 100644 --- a/src/Explorer/Explorer.tsx +++ b/src/Explorer/Explorer.tsx @@ -636,6 +636,7 @@ export default class Explorer { dataExplorerArea: Constants.Areas.ResourceTree, }); + scenarioMonitor.startPhase(MetricScenario.DatabaseLoad, ApplicationMetricPhase.CollectionsLoaded); try { await Promise.all( databasesToLoad.map(async (database: ViewModels.Database) => { @@ -647,13 +648,16 @@ export default class Explorer { useTabs .getState() .refreshActiveTab((tab) => tab.collection && tab.collection.getDatabase().id() === database.id()); - TelemetryProcessor.traceSuccess( - Action.LoadCollections, - { dataExplorerArea: Constants.Areas.ResourceTree }, - startKey, - ); }), ); + TelemetryProcessor.traceSuccess( + Action.LoadCollections, + { dataExplorerArea: Constants.Areas.ResourceTree }, + startKey, + ); + scenarioMonitor.completePhase(MetricScenario.DatabaseLoad, ApplicationMetricPhase.CollectionsLoaded); + // Start DatabaseTreeRendered — React render cycle will complete it in ResourceTree + scenarioMonitor.startPhase(MetricScenario.DatabaseLoad, ApplicationMetricPhase.DatabaseTreeRendered); } catch (error) { TelemetryProcessor.traceFailure( Action.LoadCollections, @@ -664,6 +668,7 @@ export default class Explorer { }, startKey, ); + scenarioMonitor.failPhase(MetricScenario.DatabaseLoad, ApplicationMetricPhase.CollectionsLoaded); } } @@ -1203,10 +1208,15 @@ export default class Explorer { } if (userContext.apiType !== "Postgres" && userContext.apiType !== "VCoreMongo") { - this.databasesRefreshed = - userContext.authType === AuthType.ResourceToken - ? this.refreshDatabaseForResourceToken() - : this.refreshAllDatabases(); + if (userContext.authType === AuthType.ResourceToken) { + scenarioMonitor.skipPhase(MetricScenario.DatabaseLoad, ApplicationMetricPhase.CollectionsLoaded); + scenarioMonitor.skipPhase(MetricScenario.DatabaseLoad, ApplicationMetricPhase.DatabaseTreeRendered); + this.databasesRefreshed = this.refreshDatabaseForResourceToken().then(() => { + scenarioMonitor.completePhase(MetricScenario.DatabaseLoad, ApplicationMetricPhase.DatabasesFetched); + }); + } else { + this.databasesRefreshed = this.refreshAllDatabases(); + } await this.databasesRefreshed; // await: we rely on the databases to be loaded before restoring the tabs further in the flow } @@ -1274,16 +1284,23 @@ export default class Explorer { return; } + const startKey = TelemetryProcessor.traceStart(Action.RefreshSampleData, { + dataExplorerArea: Constants.Areas.ResourceTree, + databaseId, + }); readSampleCollection() .then((collection: DataModels.Collection) => { if (!collection) { + TelemetryProcessor.traceSuccess(Action.RefreshSampleData, { sampleCollectionFound: false }, startKey); return; } const sampleDataResourceTokenCollection = new ResourceTokenCollection(this, databaseId, collection, true); useDatabases.setState({ sampleDataResourceTokenCollection }); + TelemetryProcessor.traceSuccess(Action.RefreshSampleData, { sampleCollectionFound: true }, startKey); }) .catch((error) => { + TelemetryProcessor.traceFailure(Action.RefreshSampleData, { error: getErrorMessage(error) }, startKey); Logger.logError(getErrorMessage(error), "Explorer/refreshSampleData"); }); } diff --git a/src/Explorer/Notebook/useNotebook.ts b/src/Explorer/Notebook/useNotebook.ts index 64acb2e3f..1ab4171fd 100644 --- a/src/Explorer/Notebook/useNotebook.ts +++ b/src/Explorer/Notebook/useNotebook.ts @@ -129,6 +129,9 @@ export const useNotebook: UseStore = create((set, get) => ({ : databaseAccount?.properties?.writeLocations?.[0]?.locationName.toLowerCase(); const disallowedLocationsUri: string = `${configContext.PORTAL_BACKEND_ENDPOINT}/api/disallowedlocations`; const authorizationHeader = getAuthorizationHeader(); + const startKey = TelemetryProcessor.traceStart(Action.RefreshNotebooksEnabled, { + dataExplorerArea: "Notebook", + }); try { const response = await fetch(disallowedLocationsUri, { method: "POST", @@ -155,9 +158,11 @@ export const useNotebook: UseStore = create((set, get) => ({ // firstWriteLocation should not be disallowed const isAccountInAllowedLocation = firstWriteLocation && disallowedLocations.indexOf(firstWriteLocation) === -1; set({ isNotebooksEnabledForAccount: isAccountInAllowedLocation }); + TelemetryProcessor.traceSuccess(Action.RefreshNotebooksEnabled, { isAccountInAllowedLocation }, startKey); } catch (error) { Logger.logError(getErrorMessage(error), "Explorer/isNotebooksEnabledForAccount"); set({ isNotebooksEnabledForAccount: false }); + TelemetryProcessor.traceFailure(Action.RefreshNotebooksEnabled, { error: getErrorMessage(error) }, startKey); } }, findItem: (root: NotebookContentItem, item: NotebookContentItem): NotebookContentItem => { @@ -304,6 +309,9 @@ export const useNotebook: UseStore = create((set, get) => ({ setContainerStatus: (containerStatus: ContainerInfo) => set({ containerStatus }), getPhoenixStatus: async () => { if (get().isPhoenixNotebooks === undefined || get().isPhoenixFeatures === undefined) { + const startKey = TelemetryProcessor.traceStart(Action.CheckPhoenixStatus, { + dataExplorerArea: "Notebook", + }); let isPhoenixNotebooks = false; let isPhoenixFeatures = false; @@ -328,6 +336,7 @@ export const useNotebook: UseStore = create((set, get) => ({ } set({ isPhoenixNotebooks: isPhoenixNotebooks }); set({ isPhoenixFeatures: isPhoenixFeatures }); + TelemetryProcessor.traceSuccess(Action.CheckPhoenixStatus, { isPhoenixNotebooks, isPhoenixFeatures }, startKey); } }, setIsPhoenixNotebooks: (isPhoenixNotebooks: boolean) => set({ isPhoenixNotebooks: isPhoenixNotebooks }), diff --git a/src/Explorer/QueryCopilot/Shared/QueryCopilotClient.ts b/src/Explorer/QueryCopilot/Shared/QueryCopilotClient.ts index ede7f1933..4d756ca54 100644 --- a/src/Explorer/QueryCopilot/Shared/QueryCopilotClient.ts +++ b/src/Explorer/QueryCopilot/Shared/QueryCopilotClient.ts @@ -63,20 +63,27 @@ export const isCopilotFeatureRegistered = async (subscriptionId: string): Promis const authorizationHeader: AuthorizationTokenHeaderMetadata = getAuthorizationHeader(); const headers = { [authorizationHeader.header]: authorizationHeader.token }; + const startKey = traceStart(Action.CheckCopilotFeatureRegistration, { + dataExplorerArea: Areas.Copilot, + }); let response; try { response = await fetchWithTimeout(url, headers); } catch (error) { + traceFailure(Action.CheckCopilotFeatureRegistration, { error: String(error) }, startKey); return false; } if (!response?.ok) { + traceFailure(Action.CheckCopilotFeatureRegistration, { status: response?.status }, startKey); return false; } const featureRegistration = (await response?.json()) as FeatureRegistration; - return featureRegistration?.properties?.state === "Registered"; + const registered = featureRegistration?.properties?.state === "Registered"; + traceSuccess(Action.CheckCopilotFeatureRegistration, { registered }, startKey); + return registered; }; export const getCopilotEnabled = async (): Promise => { @@ -86,20 +93,27 @@ export const getCopilotEnabled = async (): Promise => { const authorizationHeader: AuthorizationTokenHeaderMetadata = getAuthorizationHeader(); const headers = { [authorizationHeader.header]: authorizationHeader.token }; + const startKey = traceStart(Action.GetCopilotEnabled, { + dataExplorerArea: Areas.Copilot, + }); let response; try { response = await fetchWithTimeout(url, headers); } catch (error) { + traceFailure(Action.GetCopilotEnabled, { error: String(error) }, startKey); return false; } if (!response?.ok) { + traceFailure(Action.GetCopilotEnabled, { status: response?.status }, startKey); return false; } const copilotPortalConfiguration = (await response?.json()) as CopilotEnabledConfiguration; - return copilotPortalConfiguration?.isEnabled; + const isEnabled = copilotPortalConfiguration?.isEnabled; + traceSuccess(Action.GetCopilotEnabled, { isEnabled }, startKey); + return isEnabled; }; export const allocatePhoenixContainer = async ({ diff --git a/src/Explorer/Tree/Database.tsx b/src/Explorer/Tree/Database.tsx index 5ad2769d8..753c991b7 100644 --- a/src/Explorer/Tree/Database.tsx +++ b/src/Explorer/Tree/Database.tsx @@ -158,64 +158,82 @@ export default class Database implements ViewModels.Database { if (restart) { this.collectionsContinuationToken = undefined; } + const startKey = TelemetryProcessor.traceStart(Action.LoadCollectionsPerDatabase, { + dataExplorerArea: Constants.Areas.ResourceTree, + databaseId: this.id(), + }); const containerPaginationEnabled = StorageUtility.LocalStorageUtility.getEntryString(StorageUtility.StorageKey.ContainerPaginationEnabled) === "true"; - if (containerPaginationEnabled) { - const collectionsWithPagination: DataModels.CollectionsWithPagination = await readCollectionsWithPagination( - this.id(), - this.collectionsContinuationToken, - ); + try { + if (containerPaginationEnabled) { + const collectionsWithPagination: DataModels.CollectionsWithPagination = await readCollectionsWithPagination( + this.id(), + this.collectionsContinuationToken, + ); - if (collectionsWithPagination.collections?.length === Constants.Queries.containersPerPage) { - this.collectionsContinuationToken = collectionsWithPagination.continuationToken; - } else { - this.collectionsContinuationToken = undefined; - } - collections = collectionsWithPagination.collections; - } else { - collections = await readCollections(this.id()); - } - - // TODO Remove - // This is a hack to make Mongo collections read via ARM have a SQL-ish partitionKey property - if (userContext.apiType === "Mongo" && userContext.authType === AuthType.AAD) { - collections.map((collection) => { - if (collection.shardKey) { - const shardKey = Object.keys(collection.shardKey)[0]; - collection.partitionKey = { - version: undefined, - kind: "Hash", - paths: [`/"$v"/"${shardKey.split(".").join(`"/"$v"/"`)}"/"$v"`], - }; + if (collectionsWithPagination.collections?.length === Constants.Queries.containersPerPage) { + this.collectionsContinuationToken = collectionsWithPagination.continuationToken; } else { - collection.partitionKey = { - paths: ["/'$v'/'_partitionKey'/'$v'"], - kind: "Hash", - version: 2, - systemKey: true, - }; + this.collectionsContinuationToken = undefined; } + collections = collectionsWithPagination.collections; + } else { + collections = await readCollections(this.id()); + } + + // TODO Remove + // This is a hack to make Mongo collections read via ARM have a SQL-ish partitionKey property + if (userContext.apiType === "Mongo" && userContext.authType === AuthType.AAD) { + collections.map((collection) => { + if (collection.shardKey) { + const shardKey = Object.keys(collection.shardKey)[0]; + collection.partitionKey = { + version: undefined, + kind: "Hash", + paths: [`/"$v"/"${shardKey.split(".").join(`"/"$v"/"`)}"/"$v"`], + }; + } else { + collection.partitionKey = { + paths: ["/'$v'/'_partitionKey'/'$v'"], + kind: "Hash", + version: 2, + systemKey: true, + }; + } + }); + } + const deltaCollections = this.getDeltaCollections(collections); + + collections.forEach((collection: DataModels.Collection) => { + this.addSchema(collection); }); + + deltaCollections.toAdd.forEach((collection: DataModels.Collection) => { + const collectionVM: Collection = new Collection(this.container, this.id(), collection); + collectionVMs.push(collectionVM); + }); + + //merge collections + this.addCollectionsToList(collectionVMs); + if (!containerPaginationEnabled || restart) { + this.deleteCollectionsFromList(deltaCollections.toDelete); + } + + useDatabases.getState().updateDatabase(this); + TelemetryProcessor.traceSuccess( + Action.LoadCollectionsPerDatabase, + { dataExplorerArea: Constants.Areas.ResourceTree, databaseId: this.id(), collectionCount: collections?.length }, + startKey, + ); + } catch (error) { + TelemetryProcessor.traceFailure( + Action.LoadCollectionsPerDatabase, + { dataExplorerArea: Constants.Areas.ResourceTree, databaseId: this.id(), error: error?.message }, + startKey, + ); + throw error; } - const deltaCollections = this.getDeltaCollections(collections); - - collections.forEach((collection: DataModels.Collection) => { - this.addSchema(collection); - }); - - deltaCollections.toAdd.forEach((collection: DataModels.Collection) => { - const collectionVM: Collection = new Collection(this.container, this.id(), collection); - collectionVMs.push(collectionVM); - }); - - //merge collections - this.addCollectionsToList(collectionVMs); - if (!containerPaginationEnabled || restart) { - this.deleteCollectionsFromList(deltaCollections.toDelete); - } - - useDatabases.getState().updateDatabase(this); } public async openAddCollection(database: Database): Promise { diff --git a/src/Metrics/MetricScenarioConfigs.ts b/src/Metrics/MetricScenarioConfigs.ts index 3ce306ae8..41ee127ab 100644 --- a/src/Metrics/MetricScenarioConfigs.ts +++ b/src/Metrics/MetricScenarioConfigs.ts @@ -3,15 +3,28 @@ import { ApplicationMetricPhase, CommonMetricPhase, ScenarioConfig } from "./Sce export const scenarioConfigs: Record = { [MetricScenario.ApplicationLoad]: { - requiredPhases: [ApplicationMetricPhase.ExplorerInitialized, CommonMetricPhase.Interactive], + requiredPhases: [ + ApplicationMetricPhase.PlatformConfigured, + ApplicationMetricPhase.CopilotConfigured, + ApplicationMetricPhase.SampleDataLoaded, + ApplicationMetricPhase.ExplorerInitialized, + CommonMetricPhase.Interactive, + ], + deferredPhases: [ + ApplicationMetricPhase.CopilotConfigured, + ApplicationMetricPhase.SampleDataLoaded, + ApplicationMetricPhase.ExplorerInitialized, + ], timeoutMs: 10000, }, [MetricScenario.DatabaseLoad]: { requiredPhases: [ ApplicationMetricPhase.DatabasesFetched, + ApplicationMetricPhase.CollectionsLoaded, ApplicationMetricPhase.DatabaseTreeRendered, CommonMetricPhase.Interactive, ], + deferredPhases: [ApplicationMetricPhase.CollectionsLoaded, ApplicationMetricPhase.DatabaseTreeRendered], timeoutMs: 10000, }, }; diff --git a/src/Metrics/ScenarioConfig.ts b/src/Metrics/ScenarioConfig.ts index 6f91d46ec..5aa098405 100644 --- a/src/Metrics/ScenarioConfig.ts +++ b/src/Metrics/ScenarioConfig.ts @@ -9,7 +9,11 @@ export enum CommonMetricPhase { // Application-specific phases export enum ApplicationMetricPhase { ExplorerInitialized = "ExplorerInitialized", + PlatformConfigured = "PlatformConfigured", + CopilotConfigured = "CopilotConfigured", + SampleDataLoaded = "SampleDataLoaded", DatabasesFetched = "DatabasesFetched", + CollectionsLoaded = "CollectionsLoaded", DatabaseTreeRendered = "DatabaseTreeRendered", } @@ -18,6 +22,7 @@ export type MetricPhase = CommonMetricPhase | ApplicationMetricPhase; export interface ScenarioConfig { requiredPhases: TPhase[]; + deferredPhases?: TPhase[]; // Phases not auto-started at scenario start; started explicitly via startPhase() timeoutMs: number; validate?: (ctx: ScenarioContextSnapshot) => boolean; // Optional custom validation } diff --git a/src/Metrics/ScenarioMonitor.test.ts b/src/Metrics/ScenarioMonitor.test.ts index 7c4c67e5b..7101ae90c 100644 --- a/src/Metrics/ScenarioMonitor.test.ts +++ b/src/Metrics/ScenarioMonitor.test.ts @@ -110,7 +110,13 @@ describe("ScenarioMonitor", () => { // Start scenario scenarioMonitor.start(MetricScenario.ApplicationLoad); - // Complete all phases to emit + // Complete all phases to emit (PlatformConfigured auto-started, deferred phases need start+complete) + scenarioMonitor.completePhase(MetricScenario.ApplicationLoad, ApplicationMetricPhase.PlatformConfigured); + scenarioMonitor.startPhase(MetricScenario.ApplicationLoad, ApplicationMetricPhase.CopilotConfigured); + scenarioMonitor.completePhase(MetricScenario.ApplicationLoad, ApplicationMetricPhase.CopilotConfigured); + scenarioMonitor.startPhase(MetricScenario.ApplicationLoad, ApplicationMetricPhase.SampleDataLoaded); + scenarioMonitor.completePhase(MetricScenario.ApplicationLoad, ApplicationMetricPhase.SampleDataLoaded); + scenarioMonitor.startPhase(MetricScenario.ApplicationLoad, ApplicationMetricPhase.ExplorerInitialized); scenarioMonitor.completePhase(MetricScenario.ApplicationLoad, ApplicationMetricPhase.ExplorerInitialized); scenarioMonitor.completePhase(MetricScenario.ApplicationLoad, CommonMetricPhase.Interactive); @@ -161,13 +167,13 @@ describe("ScenarioMonitor", () => { it("emits healthy even with partial phase completion and expected failure", () => { scenarioMonitor.start(MetricScenario.ApplicationLoad); - // Complete one phase - scenarioMonitor.completePhase(MetricScenario.ApplicationLoad, ApplicationMetricPhase.ExplorerInitialized); + // Complete one non-deferred phase + scenarioMonitor.completePhase(MetricScenario.ApplicationLoad, ApplicationMetricPhase.PlatformConfigured); // Mark expected failure scenarioMonitor.markExpectedFailure(); - // Let timeout fire (Interactive phase not completed) + // Let timeout fire (deferred phases and Interactive not completed) jest.advanceTimersByTime(10000); expect(reportMetric).toHaveBeenCalledWith( @@ -175,7 +181,7 @@ describe("ScenarioMonitor", () => { healthy: true, timedOut: true, hasExpectedFailure: true, - completedPhases: expect.arrayContaining(["ExplorerInitialized"]), + completedPhases: expect.arrayContaining(["PlatformConfigured"]), }), ); }); @@ -216,7 +222,13 @@ describe("ScenarioMonitor", () => { it("emits healthy when all phases complete", () => { scenarioMonitor.start(MetricScenario.ApplicationLoad); - // Complete all required phases + // Complete all required phases (PlatformConfigured + Interactive auto-started, deferred need start) + scenarioMonitor.completePhase(MetricScenario.ApplicationLoad, ApplicationMetricPhase.PlatformConfigured); + scenarioMonitor.startPhase(MetricScenario.ApplicationLoad, ApplicationMetricPhase.CopilotConfigured); + scenarioMonitor.completePhase(MetricScenario.ApplicationLoad, ApplicationMetricPhase.CopilotConfigured); + scenarioMonitor.startPhase(MetricScenario.ApplicationLoad, ApplicationMetricPhase.SampleDataLoaded); + scenarioMonitor.completePhase(MetricScenario.ApplicationLoad, ApplicationMetricPhase.SampleDataLoaded); + scenarioMonitor.startPhase(MetricScenario.ApplicationLoad, ApplicationMetricPhase.ExplorerInitialized); scenarioMonitor.completePhase(MetricScenario.ApplicationLoad, ApplicationMetricPhase.ExplorerInitialized); scenarioMonitor.completePhase(MetricScenario.ApplicationLoad, CommonMetricPhase.Interactive); @@ -225,7 +237,13 @@ describe("ScenarioMonitor", () => { scenario: MetricScenario.ApplicationLoad, healthy: true, timedOut: false, - completedPhases: expect.arrayContaining(["ExplorerInitialized", "Interactive"]), + completedPhases: expect.arrayContaining([ + "PlatformConfigured", + "CopilotConfigured", + "SampleDataLoaded", + "ExplorerInitialized", + "Interactive", + ]), }), ); }); @@ -233,8 +251,8 @@ describe("ScenarioMonitor", () => { it("does not emit until all phases complete", () => { scenarioMonitor.start(MetricScenario.ApplicationLoad); - // Complete only one phase - scenarioMonitor.completePhase(MetricScenario.ApplicationLoad, ApplicationMetricPhase.ExplorerInitialized); + // Complete only one non-deferred phase + scenarioMonitor.completePhase(MetricScenario.ApplicationLoad, ApplicationMetricPhase.PlatformConfigured); expect(reportMetric).not.toHaveBeenCalled(); }); @@ -246,7 +264,13 @@ describe("ScenarioMonitor", () => { scenarioMonitor.start(MetricScenario.ApplicationLoad); scenarioMonitor.start(MetricScenario.DatabaseLoad); - // Complete ApplicationLoad + // Complete ApplicationLoad (all phases including deferred) + scenarioMonitor.completePhase(MetricScenario.ApplicationLoad, ApplicationMetricPhase.PlatformConfigured); + scenarioMonitor.startPhase(MetricScenario.ApplicationLoad, ApplicationMetricPhase.CopilotConfigured); + scenarioMonitor.completePhase(MetricScenario.ApplicationLoad, ApplicationMetricPhase.CopilotConfigured); + scenarioMonitor.startPhase(MetricScenario.ApplicationLoad, ApplicationMetricPhase.SampleDataLoaded); + scenarioMonitor.completePhase(MetricScenario.ApplicationLoad, ApplicationMetricPhase.SampleDataLoaded); + scenarioMonitor.startPhase(MetricScenario.ApplicationLoad, ApplicationMetricPhase.ExplorerInitialized); scenarioMonitor.completePhase(MetricScenario.ApplicationLoad, ApplicationMetricPhase.ExplorerInitialized); scenarioMonitor.completePhase(MetricScenario.ApplicationLoad, CommonMetricPhase.Interactive); diff --git a/src/Metrics/ScenarioMonitor.ts b/src/Metrics/ScenarioMonitor.ts index 9782a6cb0..c0fc28b2c 100644 --- a/src/Metrics/ScenarioMonitor.ts +++ b/src/Metrics/ScenarioMonitor.ts @@ -87,8 +87,12 @@ class ScenarioMonitor { hasExpectedFailure: false, }; - // Start all required phases at scenario start time + // Start all required phases at scenario start time, except deferred ones + const deferredSet = new Set(config.deferredPhases ?? []); config.requiredPhases.forEach((phase) => { + if (deferredSet.has(phase)) { + return; // Deferred phases are started explicitly via startPhase() + } const phaseStartMarkName = `scenario_${scenario}_${phase}_start`; performance.mark(phaseStartMarkName); ctx.phases.set(phase, { startMarkName: phaseStartMarkName }); @@ -135,6 +139,11 @@ class ScenarioMonitor { if (!ctx || ctx.emitted || !ctx.config.requiredPhases.includes(phase) || ctx.phases.has(phase)) { return; } + // Only deferred phases can be started via startPhase(); non-deferred are auto-started in start() + const isDeferredPhase = ctx.config.deferredPhases?.includes(phase) ?? false; + if (!isDeferredPhase) { + return; + } const startMarkName = `scenario_${scenario}_${phase}_start`; performance.mark(startMarkName); @@ -147,10 +156,37 @@ class ScenarioMonitor { }); } + /** + * Marks a phase as skipped (e.g. copilot disabled). Removes the phase from + * requiredPhases so it no longer blocks scenario completion. + */ + skipPhase(scenario: MetricScenario, phase: MetricPhase) { + const ctx = this.contexts.get(scenario); + if (!ctx || ctx.emitted) { + return; + } + // Remove from requiredPhases so it doesn't block completion + ctx.config = { + ...ctx.config, + requiredPhases: ctx.config.requiredPhases.filter((p) => p !== phase), + deferredPhases: ctx.config.deferredPhases?.filter((p) => p !== phase), + }; + + this.devLog(`phase_skip: ${scenario}.${phase}`); + + traceMark(Action.MetricsScenario, { + event: "phase_skip", + scenario, + phase, + }); + + this.tryEmitIfReady(ctx); + } + completePhase(scenario: MetricScenario, phase: MetricPhase) { const ctx = this.contexts.get(scenario); const phaseCtx = ctx?.phases.get(phase); - if (!ctx || ctx.emitted || !ctx.config.requiredPhases.includes(phase) || !phaseCtx) { + if (!ctx || ctx.emitted || ctx.completed.has(phase) || !ctx.config.requiredPhases.includes(phase) || !phaseCtx) { return; } @@ -356,8 +392,7 @@ class ScenarioMonitor { private cleanupPerformanceEntries(ctx: InternalScenarioContext) { performance.clearMarks(ctx.startMarkName); - ctx.config.requiredPhases.forEach((phase) => { - const phaseCtx = ctx.phases.get(phase); + ctx.phases.forEach((phaseCtx, phase) => { if (phaseCtx?.startMarkName) { performance.clearMarks(phaseCtx.startMarkName); } diff --git a/src/Shared/Telemetry/TelemetryConstants.ts b/src/Shared/Telemetry/TelemetryConstants.ts index 4c2b8cf16..8eedad58f 100644 --- a/src/Shared/Telemetry/TelemetryConstants.ts +++ b/src/Shared/Telemetry/TelemetryConstants.ts @@ -152,6 +152,22 @@ export enum Action { CloudShellTerminalSession, OpenVSCode, ImportSampleData, + + // Tracing for ApplicationLoad & DatabaseLoad scenarios + ConfigurePortal, + FetchAccountKeys, + AcquireMsalToken, + UpdateCopilotContext, + GetCopilotEnabled, + CheckCopilotFeatureRegistration, + UpdateSampleDataContext, + RefreshSampleData, + ReadDatabases, + ReadCollections, + LoadCollectionsPerDatabase, + RefreshNotebooksEnabled, + CheckPhoenixStatus, + CheckFeatureRegistration, } export const ActionModifiers = { diff --git a/src/Utils/AuthorizationUtils.ts b/src/Utils/AuthorizationUtils.ts index 26dcd5d5d..a24cb452e 100644 --- a/src/Utils/AuthorizationUtils.ts +++ b/src/Utils/AuthorizationUtils.ts @@ -10,7 +10,7 @@ import { DatabaseAccount } from "../Contracts/DataModels"; import * as ViewModels from "../Contracts/ViewModels"; import { isExpectedError } from "../Metrics/ErrorClassification"; import { scenarioMonitor } from "../Metrics/ScenarioMonitor"; -import { trace, traceFailure } from "../Shared/Telemetry/TelemetryProcessor"; +import { trace, traceFailure, traceStart, traceSuccess } from "../Shared/Telemetry/TelemetryProcessor"; import { UserContext, userContext } from "../UserContext"; export function getAuthorizationHeader(): ViewModels.AuthorizationTokenHeaderMetadata { @@ -74,7 +74,11 @@ export async function acquireMsalTokenForAccount( silent: boolean = false, user_hint?: string, ) { + const msalStartKey = traceStart(Action.AcquireMsalToken, { + acquireTokenType: silent ? "silent" : "interactive", + }); if (userContext.databaseAccount.properties?.documentEndpoint === undefined) { + traceFailure(Action.AcquireMsalToken, { error: "No document endpoint" }, msalStartKey); throw new Error("Database account has no document endpoint defined"); } let hrefEndpoint = ""; @@ -107,6 +111,7 @@ export async function acquireMsalTokenForAccount( // See https://learn.microsoft.com/en-us/entra/identity-platform/msal-js-sso#sso-between-different-apps try { const loginResponse = await msalInstance.ssoSilent(loginRequest); + traceSuccess(Action.AcquireMsalToken, { method: "ssoSilent" }, msalStartKey); return loginResponse.accessToken; } catch (silentError) { trace(Action.SignInAad, ActionModifiers.Mark, { @@ -122,6 +127,7 @@ export async function acquireMsalTokenForAccount( // See https://learn.microsoft.com/en-us/entra/identity-platform/msal-js-prompt-behavior#interactive-requests-with-promptnone // The hint will be used to pre-fill the username field in the popup if silent is false. const loginResponse = await msalInstance.loginPopup({ prompt: silent ? "none" : "login", ...loginRequest }); + traceSuccess(Action.AcquireMsalToken, { method: "loginPopup" }, msalStartKey); return loginResponse.accessToken; } catch (error) { traceFailure(Action.SignInAad, { @@ -129,6 +135,7 @@ export async function acquireMsalTokenForAccount( acquireTokenType: silent ? "silent" : "interactive", errorMessage: JSON.stringify(error), }); + traceFailure(Action.AcquireMsalToken, { error: JSON.stringify(error) }, msalStartKey); // Mark expected failure for health metrics so timeout emits healthy if (isExpectedError(error)) { scenarioMonitor.markExpectedFailure(); @@ -161,7 +168,8 @@ export async function acquireTokenWithMsal( try { // attempt silent acquisition first - return (await msalInstance.acquireTokenSilent(tokenRequest)).accessToken; + const token = (await msalInstance.acquireTokenSilent(tokenRequest)).accessToken; + return token; } catch (silentError) { if (silentError instanceof msal.InteractionRequiredAuthError && silent === false) { try { diff --git a/src/Utils/FeatureRegistrationUtils.ts b/src/Utils/FeatureRegistrationUtils.ts index 9cc5676c9..86ffd8fdb 100644 --- a/src/Utils/FeatureRegistrationUtils.ts +++ b/src/Utils/FeatureRegistrationUtils.ts @@ -1,6 +1,8 @@ import { configContext } from "ConfigContext"; import { FeatureRegistration } from "Contracts/DataModels"; import { AuthorizationTokenHeaderMetadata } from "Contracts/ViewModels"; +import { Action } from "Shared/Telemetry/TelemetryConstants"; +import { traceFailure, traceStart, traceSuccess } from "Shared/Telemetry/TelemetryProcessor"; import { getAuthorizationHeader } from "Utils/AuthorizationUtils"; export const featureRegistered = async (subscriptionId: string, feature: string) => { @@ -9,20 +11,27 @@ export const featureRegistered = async (subscriptionId: string, feature: string) const authorizationHeader: AuthorizationTokenHeaderMetadata = getAuthorizationHeader(); const headers = { [authorizationHeader.header]: authorizationHeader.token }; + const startKey = traceStart(Action.CheckFeatureRegistration, { + feature, + }); let response; try { response = await _fetchWithTimeout(url, headers); } catch (error) { + traceFailure(Action.CheckFeatureRegistration, { feature, error: String(error) }, startKey); return false; } if (!response?.ok) { + traceFailure(Action.CheckFeatureRegistration, { feature, status: response?.status }, startKey); return false; } const featureRegistration = (await response?.json()) as FeatureRegistration; - return featureRegistration?.properties?.state === "Registered"; + const registered = featureRegistration?.properties?.state === "Registered"; + traceSuccess(Action.CheckFeatureRegistration, { feature, registered }, startKey); + return registered; }; async function _fetchWithTimeout( diff --git a/src/hooks/useKnockoutExplorer.ts b/src/hooks/useKnockoutExplorer.ts index b4a568e2c..eecb8ecaa 100644 --- a/src/hooks/useKnockoutExplorer.ts +++ b/src/hooks/useKnockoutExplorer.ts @@ -49,6 +49,9 @@ import { HostedExplorerChildFrame, ResourceToken, } from "../HostedExplorerChildFrame"; +import MetricScenario from "../Metrics/MetricEvents"; +import { ApplicationMetricPhase } from "../Metrics/ScenarioConfig"; +import { scenarioMonitor } from "../Metrics/ScenarioMonitor"; import { emulatorAccount } from "../Platform/Emulator/emulatorAccount"; import { parseResourceTokenConnectionString } from "../Platform/Hosted/Helpers/ResourceTokenUtils"; import { @@ -57,6 +60,8 @@ import { } from "../Platform/Hosted/HostedUtils"; import { extractFeatures } from "../Platform/Hosted/extractFeatures"; import { DefaultExperienceUtility } from "../Shared/DefaultExperienceUtility"; +import { Action } from "../Shared/Telemetry/TelemetryConstants"; +import { traceFailure, traceStart, traceSuccess } from "../Shared/Telemetry/TelemetryProcessor"; import { FabricArtifactInfo, Node, PortalEnv, updateUserContext, userContext } from "../UserContext"; import { acquireMsalTokenForAccount, @@ -88,23 +93,37 @@ export function useKnockoutExplorer(platform: Platform): Explorer { } let explorer: Explorer; - if (platform === Platform.Hosted) { - explorer = await configureHosted(); - } else if (platform === Platform.Emulator) { - explorer = configureEmulator(); - } else if (platform === Platform.Portal) { - explorer = await configurePortal(); - } else if (platform === Platform.Fabric) { - explorer = await configureFabric(); + try { + if (platform === Platform.Hosted) { + explorer = await configureHosted(); + } else if (platform === Platform.Emulator) { + explorer = configureEmulator(); + } else if (platform === Platform.Portal) { + explorer = await configurePortal(); + } else if (platform === Platform.Fabric) { + explorer = await configureFabric(); + } + } catch (error) { + scenarioMonitor.failPhase(MetricScenario.ApplicationLoad, ApplicationMetricPhase.PlatformConfigured); + throw error; } + scenarioMonitor.completePhase(MetricScenario.ApplicationLoad, ApplicationMetricPhase.PlatformConfigured); + if (explorer && userContext.features.enableCopilot) { await updateContextForCopilot(explorer); await updateContextForSampleData(explorer); + } else { + // Explorer falsy or copilot disabled — skip deferred copilot/sampleData phases + scenarioMonitor.skipPhase(MetricScenario.ApplicationLoad, ApplicationMetricPhase.CopilotConfigured); + scenarioMonitor.skipPhase(MetricScenario.ApplicationLoad, ApplicationMetricPhase.SampleDataLoaded); } restoreOpenTabs(); + // Complete ExplorerInitialized — React state update that unblocks ResourceTree + scenarioMonitor.startPhase(MetricScenario.ApplicationLoad, ApplicationMetricPhase.ExplorerInitialized); setExplorer(explorer); + scenarioMonitor.completePhase(MetricScenario.ApplicationLoad, ApplicationMetricPhase.ExplorerInitialized); } }; effect(); @@ -657,6 +676,11 @@ function configureEmulator(): Explorer { export async function fetchAndUpdateKeys(subscriptionId: string, resourceGroup: string, account: string) { Logger.logInfo(`Fetching keys for ${userContext.apiType} account ${account}`, "Explorer/fetchAndUpdateKeys"); + const startKey = traceStart(Action.FetchAccountKeys, { + dataExplorerArea: "ResourceTree", + apiType: userContext.apiType, + accountName: account, + }); let keys; try { keys = await listKeys(subscriptionId, resourceGroup, account); @@ -664,6 +688,7 @@ export async function fetchAndUpdateKeys(subscriptionId: string, resourceGroup: updateUserContext({ masterKey: keys.primaryMasterKey, }); + traceSuccess(Action.FetchAccountKeys, { accountName: account }, startKey); } catch (error) { if (error.code === "AuthorizationFailed") { keys = await getReadOnlyKeys(subscriptionId, resourceGroup, account); @@ -674,18 +699,23 @@ export async function fetchAndUpdateKeys(subscriptionId: string, resourceGroup: updateUserContext({ masterKey: keys.primaryReadonlyMasterKey, }); + traceSuccess(Action.FetchAccountKeys, { accountName: account, fallbackToReadOnly: true }, startKey); } else { logConsoleError(`Error occurred fetching keys for the account." ${error.message}`); Logger.logError( `Error during fetching keys or updating user context: ${error} for ${userContext.apiType} account ${account}`, "Explorer/fetchAndUpdateKeys", ); + traceFailure(Action.FetchAccountKeys, { accountName: account, error: error.message }, startKey); throw error; } } } async function configurePortal(): Promise { + const configureStartKey = traceStart(Action.ConfigurePortal, { + dataExplorerArea: "ResourceTree", + }); updateUserContext({ authType: AuthType.AAD, }); @@ -800,6 +830,7 @@ async function configurePortal(): Promise { } explorer = new Explorer(); + traceSuccess(Action.ConfigurePortal, {}, configureStartKey); resolve(explorer); if (openAction) { @@ -1018,34 +1049,59 @@ interface PortalMessage { } async function updateContextForCopilot(explorer: Explorer): Promise { - await explorer.configureCopilot(); + scenarioMonitor.startPhase(MetricScenario.ApplicationLoad, ApplicationMetricPhase.CopilotConfigured); + const startKey = traceStart(Action.UpdateCopilotContext, { + dataExplorerArea: "ResourceTree", + }); + try { + await explorer.configureCopilot(); + traceSuccess(Action.UpdateCopilotContext, {}, startKey); + } catch (error) { + traceFailure(Action.UpdateCopilotContext, { error: error?.message }, startKey); + } finally { + scenarioMonitor.completePhase(MetricScenario.ApplicationLoad, ApplicationMetricPhase.CopilotConfigured); + } } async function updateContextForSampleData(explorer: Explorer): Promise { + scenarioMonitor.startPhase(MetricScenario.ApplicationLoad, ApplicationMetricPhase.SampleDataLoaded); const copilotEnabled = userContext.apiType === "SQL" && userContext.features.enableCopilot && useQueryCopilot.getState().copilotEnabled; if (!copilotEnabled) { + scenarioMonitor.completePhase(MetricScenario.ApplicationLoad, ApplicationMetricPhase.SampleDataLoaded); return; } - const url: string = createUri(configContext.PORTAL_BACKEND_ENDPOINT, "/api/sampledata"); - const authorizationHeader = getAuthorizationHeader(); - const headers = { [authorizationHeader.header]: authorizationHeader.token }; - - const response = await window.fetch(url, { - headers, + const startKey = traceStart(Action.UpdateSampleDataContext, { + dataExplorerArea: "ResourceTree", }); + try { + const url: string = createUri(configContext.PORTAL_BACKEND_ENDPOINT, "/api/sampledata"); + const authorizationHeader = getAuthorizationHeader(); + const headers = { [authorizationHeader.header]: authorizationHeader.token }; - if (!response.ok) { - return undefined; + const response = await window.fetch(url, { + headers, + }); + + if (!response.ok) { + traceSuccess(Action.UpdateSampleDataContext, { sampleDataAvailable: false }, startKey); + scenarioMonitor.completePhase(MetricScenario.ApplicationLoad, ApplicationMetricPhase.SampleDataLoaded); + return undefined; + } + + const data: SampledataconnectionResponse = await response.json(); + const sampleDataConnectionInfo = parseResourceTokenConnectionString(data.connectionString); + updateUserContext({ sampleDataConnectionInfo }); + + explorer.refreshSampleData(); + traceSuccess(Action.UpdateSampleDataContext, { sampleDataAvailable: true }, startKey); + } catch (error) { + traceFailure(Action.UpdateSampleDataContext, { error: error?.message }, startKey); + } finally { + scenarioMonitor.completePhase(MetricScenario.ApplicationLoad, ApplicationMetricPhase.SampleDataLoaded); } - - const data: SampledataconnectionResponse = await response.json(); - const sampleDataConnectionInfo = parseResourceTokenConnectionString(data.connectionString); - updateUserContext({ sampleDataConnectionInfo }); - - explorer.refreshSampleData(); } interface SampledataconnectionResponse {