diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..9ef525333 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,12 @@ +# Copilot / Agent Instructions + +## Copyright headers + +Existing source files carry the `.NET Foundation` copyright header: +``` +// Copyright (c) .NET Foundation and contributors. All rights reserved. +// Licensed under the MIT license. See LICENSE file in the project root for full license information. +``` + +This repo is a fork that is no longer maintained under the .NET Foundation. +**Do not add this header to new files.** Leave it on files that already have it. diff --git a/src/Microsoft.TryDotNet/ContentGenerator.cs b/src/Microsoft.TryDotNet/ContentGenerator.cs index 9c7ffb025..45f36c4cf 100644 --- a/src/Microsoft.TryDotNet/ContentGenerator.cs +++ b/src/Microsoft.TryDotNet/ContentGenerator.cs @@ -30,11 +30,18 @@ public static Task GenerateEditorPageAsync(HttpRequest request) enableLogging = enableLoggingString.FirstOrDefault()?.ToLowerInvariant() == "true"; } + string? correlationContext = null; + if (request.Query.TryGetValue("correlationContext", out var correlationContextQueryValue)) + { + correlationContext = correlationContextQueryValue.FirstOrDefault(); + } + var configuration = new { wasmRunnerUrl = wasmRunnerUri.AbsoluteUri, commandsUrl = commandsUri.AbsoluteUri, refererUrl = !string.IsNullOrWhiteSpace(referer) ? new Uri(referer, UriKind.Absolute) : null, + correlationContext, enableLogging }; @@ -61,4 +68,4 @@ public static Task GenerateEditorPageAsync(HttpRequest request) return Task.FromResult(value); } -} \ No newline at end of file +} diff --git a/src/microsoft-trydotnet-editor/src/apiService.ts b/src/microsoft-trydotnet-editor/src/apiService.ts index 715100142..51b42431e 100644 --- a/src/microsoft-trydotnet-editor/src/apiService.ts +++ b/src/microsoft-trydotnet-editor/src/apiService.ts @@ -12,6 +12,7 @@ export interface IServiceError { export interface IApiServiceConfiguration { referer?: URL; commandsUrl: URL; + correlationContext?: string; onServiceError: (error: IServiceError) => void; } @@ -25,6 +26,7 @@ export interface IApiService { function createApiServiceWithConfiguration(configuration: IApiServiceConfiguration): IApiService { + const traceId = normalizeTraceId(configuration.correlationContext); let service: IApiService = async (commands) => { let bodyContent = JSON.stringify({ commands: commands.map(command => command.toJson()) @@ -35,6 +37,9 @@ function createApiServiceWithConfiguration(configuration: IApiServiceConfigurati if (configuration.referer) { headers['Referer'] = configuration.referer.toString(); } + if (traceId) { + headers['traceparent'] = createTraceParent(traceId); + } let response = await fetch(configuration.commandsUrl.toString(), { method: 'POST', @@ -61,4 +66,31 @@ function createApiServiceWithConfiguration(configuration: IApiServiceConfigurati }; return service; -} \ No newline at end of file +} + +function normalizeTraceId(value?: string): string | null { + if (!value) { + return null; + } + + const traceParentMatch = value.match(/^00-([a-fA-F0-9]{32})-[a-fA-F0-9]{16}-[a-fA-F0-9]{2}$/); + if (traceParentMatch) { + return traceParentMatch[1].toLowerCase(); + } + + if (/^[a-fA-F0-9]{32}$/.test(value)) { + return value.toLowerCase(); + } + + return null; +} + +function createTraceParent(traceId: string): string { + return `00-${traceId}-${createSpanId()}-01`; +} + +function createSpanId(): string { + const randomValues = new Uint8Array(8); + crypto.getRandomValues(randomValues); + return Array.from(randomValues, b => b.toString(16).padStart(2, '0')).join(''); +} diff --git a/src/microsoft-trydotnet-editor/src/factory.ts b/src/microsoft-trydotnet-editor/src/factory.ts index bd93fa9da..620f0b139 100644 --- a/src/microsoft-trydotnet-editor/src/factory.ts +++ b/src/microsoft-trydotnet-editor/src/factory.ts @@ -60,6 +60,7 @@ export function createWasmProjectKernel(onServiceError: (serviceError: IServiceE const apiService = createApiService({ commandsUrl: new URL(configuration.commandsUrl), referer: configuration.refererUrl ? new URL(configuration.refererUrl) : null, + correlationContext: configuration.correlationContext, onServiceError: onServiceError }); @@ -159,5 +160,6 @@ export interface IConfiguration { wasmRunnerUrl: string, refererUrl: string, commandsUrl: string, + correlationContext?: string, enableLogging: boolean -} \ No newline at end of file +} diff --git a/src/microsoft-trydotnet-editor/tests/apiService.tests.ts b/src/microsoft-trydotnet-editor/tests/apiService.tests.ts new file mode 100644 index 000000000..4abc0bbb3 --- /dev/null +++ b/src/microsoft-trydotnet-editor/tests/apiService.tests.ts @@ -0,0 +1,59 @@ +import { expect } from "chai"; +import { describe } from "mocha"; +import { createApiService } from "../src/apiService"; + +describe("apiService", () => { + it("adds traceparent header when correlationContext is a trace id", async () => { + let capturedHeaders: any = null; + const originalFetch = globalThis.fetch; + + (globalThis as any).fetch = async (_url: string, options: any) => { + capturedHeaders = options.headers; + return { + ok: true, + json: async () => ({ events: [] }) + }; + }; + + try { + const service = createApiService({ + commandsUrl: new URL("https://example.org/commands"), + correlationContext: "0123456789abcdef0123456789abcdef", + onServiceError: () => { /* no-op */ } + }); + + await service([{ toJson: () => ({ commandType: "SubmitCode", command: {} }) } as any]); + + expect(capturedHeaders).to.have.property("traceparent"); + expect(capturedHeaders.traceparent).to.match(/^00-0123456789abcdef0123456789abcdef-[a-f0-9]{16}-01$/); + } finally { + (globalThis as any).fetch = originalFetch; + } + }); + + it("does not add traceparent header when correlationContext is missing", async () => { + let capturedHeaders: any = null; + const originalFetch = globalThis.fetch; + + (globalThis as any).fetch = async (_url: string, options: any) => { + capturedHeaders = options.headers; + return { + ok: true, + json: async () => ({ events: [] }) + }; + }; + + try { + const service = createApiService({ + commandsUrl: new URL("https://example.org/commands"), + onServiceError: () => { /* no-op */ } + }); + + await service([{ toJson: () => ({ commandType: "SubmitCode", command: {} }) } as any]); + + expect(capturedHeaders).to.not.have.property("traceparent"); + } finally { + (globalThis as any).fetch = originalFetch; + } + }); +}); diff --git a/src/microsoft-trydotnet/src/configuration.ts b/src/microsoft-trydotnet/src/configuration.ts index 34c6697c9..79b4eb6c4 100644 --- a/src/microsoft-trydotnet/src/configuration.ts +++ b/src/microsoft-trydotnet/src/configuration.ts @@ -6,6 +6,7 @@ import { MonacoEditorConfiguration } from "./editor"; export type Configuration = { hostOrigin?: string, trydotnetOrigin?: string, + correlationContext?: string, editorConfiguration?: MonacoEditorConfiguration, enableLogging?: boolean, } diff --git a/src/microsoft-trydotnet/src/internals/urlHelpers.ts b/src/microsoft-trydotnet/src/internals/urlHelpers.ts index 49db7b764..60f8b32bd 100644 --- a/src/microsoft-trydotnet/src/internals/urlHelpers.ts +++ b/src/microsoft-trydotnet/src/internals/urlHelpers.ts @@ -17,14 +17,18 @@ export function generateEditorUrl(configuration: Configuration, packageName?: st url.searchParams.append("enableLogging", "true"); } - buildQueryString(url, packageName); + buildQueryString(url, packageName, configuration.correlationContext); return url.href; } -function buildQueryString(url: URL, packageName?: string) { +function buildQueryString(url: URL, packageName?: string, correlationContext?: string) { if (packageName) { url.searchParams.append("workspaceType", packageName); } + + if (correlationContext) { + url.searchParams.append("correlationContext", correlationContext); + } } export function extractTargetOriginFromIFrame(iframe: HTMLIFrameElement): string { diff --git a/src/microsoft-trydotnet/test/session.creationapi.specs.ts b/src/microsoft-trydotnet/test/session.creationapi.specs.ts index b423db381..03713cde2 100644 --- a/src/microsoft-trydotnet/test/session.creationapi.specs.ts +++ b/src/microsoft-trydotnet/test/session.creationapi.specs.ts @@ -19,6 +19,50 @@ describe("a user", () => { configuration = { hostOrigin: "https://learn.microsoft.com" }; }); describe("with single iframe", () => { + it("includes correlationContext in editor iframe url when configured", async () => { + configuration = { + hostOrigin: "https://learn.microsoft.com", + correlationContext: "0123456789abcdef0123456789abcdef" + }; + + const dom = buildSimpleIFrameDom(configuration); + const editorIFrame = getEditorIFrame(dom); + const project = { + package: "console", + files: [{ name: "program.cs", content: "" }] + }; + + const awaitableSession = createSessionWithProjectAndOpenDocument( + configuration, + [editorIFrame], + dom.window as unknown as Window, + project, + "program.cs"); + + const iframeUrl = new URL(editorIFrame.getAttribute("src")!); + iframeUrl.searchParams.get("correlationContext").should.equal("0123456789abcdef0123456789abcdef"); + + registerForOpenProject(configuration, editorIFrame, dom.window, (files) => { + return files.map(f => { + const item: polyglotNotebooks.ProjectItem = { + relativeFilePath: f.relativeFilePath, + regionNames: [], + regionsContent: {} + }; + return item; + }); + }); + + registerForOpeDocument(configuration, editorIFrame, dom.window, (documentId) => { + const content = project.files.find(f => areSameFile(f.name, documentId.relativeFilePath))?.content ?? ""; + return content; + }); + + notifyEditorReady(configuration, dom.window); + const session = await awaitableSession; + session.should.not.be.null; + }); + it("can create a session with initial project", async () => { let dom = buildSimpleIFrameDom(configuration); let editorIFrame = getEditorIFrame(dom);