mirror of
https://github.com/Azure/cosmos-explorer.git
synced 2026-04-16 19:39:19 +01:00
Frontend performance metrics (#2439)
* Added enriched metrics * Add more traces for observability
This commit is contained in:
@@ -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<DataModels.Collection[]> {
|
||||
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<DataModels.Co
|
||||
|
||||
// Sort collections by id before returning
|
||||
collections.sort((a, b) => 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<DataModels.Co
|
||||
userContext.apiType !== "Tables" &&
|
||||
!isFabric()
|
||||
) {
|
||||
return await readCollectionsWithARM(databaseId);
|
||||
const result = await readCollectionsWithARM(databaseId);
|
||||
traceSuccess(Action.ReadCollections, { databaseId, collectionCount: result?.length, path: "ARM" }, startKey);
|
||||
return result;
|
||||
}
|
||||
|
||||
Logger.logInfo(`readCollections: calling fetchAll for database ${databaseId}`, "readCollections");
|
||||
@@ -70,8 +81,14 @@ export async function readCollections(databaseId: string): Promise<DataModels.Co
|
||||
?.length}, durationMs=${Date.now() - fetchAllStart}`,
|
||||
"readCollections",
|
||||
);
|
||||
traceSuccess(
|
||||
Action.ReadCollections,
|
||||
{ databaseId, collectionCount: sdkResponse.resources?.length, path: "SDK" },
|
||||
startKey,
|
||||
);
|
||||
return sdkResponse.resources as DataModels.Collection[];
|
||||
} catch (error) {
|
||||
traceFailure(Action.ReadCollections, { databaseId, error: error?.message }, startKey);
|
||||
handleError(error, "ReadCollections", `Error while querying containers for database ${databaseId}`);
|
||||
throw error;
|
||||
} finally {
|
||||
@@ -84,6 +101,11 @@ export async function readCollectionsWithPagination(
|
||||
continuationToken?: string,
|
||||
): Promise<DataModels.CollectionsWithPagination> {
|
||||
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 {
|
||||
|
||||
@@ -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<DataModels.Database[]> {
|
||||
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<DataModels.Database[]> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}),
|
||||
);
|
||||
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");
|
||||
});
|
||||
}
|
||||
|
||||
@@ -129,6 +129,9 @@ export const useNotebook: UseStore<NotebookState> = 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<NotebookState> = 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<NotebookState> = 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<NotebookState> = create((set, get) => ({
|
||||
}
|
||||
set({ isPhoenixNotebooks: isPhoenixNotebooks });
|
||||
set({ isPhoenixFeatures: isPhoenixFeatures });
|
||||
TelemetryProcessor.traceSuccess(Action.CheckPhoenixStatus, { isPhoenixNotebooks, isPhoenixFeatures }, startKey);
|
||||
}
|
||||
},
|
||||
setIsPhoenixNotebooks: (isPhoenixNotebooks: boolean) => set({ isPhoenixNotebooks: isPhoenixNotebooks }),
|
||||
|
||||
@@ -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<boolean> => {
|
||||
@@ -86,20 +93,27 @@ export const getCopilotEnabled = async (): Promise<boolean> => {
|
||||
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 ({
|
||||
|
||||
@@ -158,9 +158,14 @@ 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";
|
||||
try {
|
||||
if (containerPaginationEnabled) {
|
||||
const collectionsWithPagination: DataModels.CollectionsWithPagination = await readCollectionsWithPagination(
|
||||
this.id(),
|
||||
@@ -216,6 +221,19 @@ export default class Database implements ViewModels.Database {
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
public async openAddCollection(database: Database): Promise<void> {
|
||||
|
||||
@@ -3,15 +3,28 @@ import { ApplicationMetricPhase, CommonMetricPhase, ScenarioConfig } from "./Sce
|
||||
|
||||
export const scenarioConfigs: Record<MetricScenario, ScenarioConfig> = {
|
||||
[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,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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<TPhase extends string = MetricPhase> {
|
||||
requiredPhases: TPhase[];
|
||||
deferredPhases?: TPhase[]; // Phases not auto-started at scenario start; started explicitly via startPhase()
|
||||
timeoutMs: number;
|
||||
validate?: (ctx: ScenarioContextSnapshot<TPhase>) => boolean; // Optional custom validation
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,6 +93,7 @@ export function useKnockoutExplorer(platform: Platform): Explorer {
|
||||
}
|
||||
|
||||
let explorer: Explorer;
|
||||
try {
|
||||
if (platform === Platform.Hosted) {
|
||||
explorer = await configureHosted();
|
||||
} else if (platform === Platform.Emulator) {
|
||||
@@ -97,14 +103,27 @@ export function useKnockoutExplorer(platform: Platform): Explorer {
|
||||
} 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<Explorer> {
|
||||
const configureStartKey = traceStart(Action.ConfigurePortal, {
|
||||
dataExplorerArea: "ResourceTree",
|
||||
});
|
||||
updateUserContext({
|
||||
authType: AuthType.AAD,
|
||||
});
|
||||
@@ -800,6 +830,7 @@ async function configurePortal(): Promise<Explorer> {
|
||||
}
|
||||
|
||||
explorer = new Explorer();
|
||||
traceSuccess(Action.ConfigurePortal, {}, configureStartKey);
|
||||
resolve(explorer);
|
||||
|
||||
if (openAction) {
|
||||
@@ -1018,17 +1049,34 @@ interface PortalMessage {
|
||||
}
|
||||
|
||||
async function updateContextForCopilot(explorer: Explorer): Promise<void> {
|
||||
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<void> {
|
||||
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 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 };
|
||||
@@ -1038,6 +1086,8 @@ async function updateContextForSampleData(explorer: Explorer): Promise<void> {
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
traceSuccess(Action.UpdateSampleDataContext, { sampleDataAvailable: false }, startKey);
|
||||
scenarioMonitor.completePhase(MetricScenario.ApplicationLoad, ApplicationMetricPhase.SampleDataLoaded);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -1046,6 +1096,12 @@ async function updateContextForSampleData(explorer: Explorer): Promise<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
interface SampledataconnectionResponse {
|
||||
|
||||
Reference in New Issue
Block a user