Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
515dad0
Add Exie conversation tracking and failure diagnostics
ejsmith Sep 9, 2026
5ccd9e6
Classify streamed provider errors and thrown tool outcomes
ejsmith Sep 9, 2026
fe5d351
Isolate Exie tracing and classify provider exceptions
ejsmith Sep 9, 2026
607b564
Distinguish Exie deadlines from client cancellations
ejsmith Sep 9, 2026
9f55a0b
Align tool cancellation metrics with turn outcomes
ejsmith Sep 9, 2026
4196ca5
Normalize Exie conversation IDs across telemetry and requests
ejsmith Sep 9, 2026
77e63e7
Record Exie usage without chat content
ejsmith Sep 9, 2026
39588e2
Align provider HTTP failure diagnostic codes
ejsmith Sep 9, 2026
65c5b30
Classify malformed provider response structures
ejsmith Sep 9, 2026
a144b70
Add an admin switch for full Exie conversation logging
ejsmith Sep 9, 2026
c1dcc96
Keep provider HTTP outcomes consistent for redirects
ejsmith Sep 9, 2026
2350fb3
Let users override the Exie conversation sharing default
ejsmith Sep 9, 2026
174fc41
Resolve Exie sharing settings before reserving a turn
ejsmith Sep 9, 2026
c500262
Explain Exie outcome counts with tooltips
ejsmith Sep 9, 2026
25e45c0
Compact the Exie conversation sharing control
ejsmith Sep 9, 2026
021d07f
Enable local browser self-reporting in development
ejsmith Sep 9, 2026
6ac309e
Avoid unidentified browser sessions and label anonymous summaries
ejsmith Sep 9, 2026
91fb92b
Enable local server reporting in development Aspire runs
ejsmith Sep 9, 2026
f6c2213
Merge branch 'main' into feature/exie-session-diagnostics
ejsmith Sep 9, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ After startup:
1. Open `https://localhost:7121/` if a browser does not open automatically.
2. In `Development` mode, a global administrator user `admin@exceptionless.test` with password `tester` is created automatically.

Development Aspire runs report API and background job warnings and errors, plus Svelte browser errors, usage, and sessions, to the seeded **Exceptionless → Exceptionless** project by default (named **API** in older development data). Server reporting uses the local API endpoint, and browser telemetry uses the current browser origin, so worktrees and forwarded localhost ports work without extra configuration. Exie conversation text still follows the user's chat-sharing preference. Set `ExceptionlessApiKey` to an empty value in the AppHost environment to disable server reporting. To override the browser telemetry destination, set `PUBLIC_EXCEPTIONLESS_API_KEY` and `PUBLIC_EXCEPTIONLESS_TELEMETRY_SERVER_URL`; an empty browser key disables automatic browser reporting.

Notes:

1. Running `Exceptionless.AppHost` starts the app and required infrastructure together.
Expand Down
9 changes: 9 additions & 0 deletions src/Exceptionless.AppHost/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,9 @@
.WithUrlForEndpoint("http", u => u.DisplayLocation = UrlDisplayLocation.DetailsOnly)
.WithHttpHealthCheck("/health");

api.WithEnvironment("EX_ExceptionlessApiKey", builder.Configuration["ExceptionlessApiKey"])
.WithEnvironment("EX_ExceptionlessServerUrl", api.GetEndpoint("http"));

if (assistantApiKey is not null)
{
api.WithEnvironment("EX_Assistant__ApiKey", assistantApiKey);
Expand All @@ -138,6 +141,9 @@
.WithReference(storageBlobs, "AzureStorage")
.WithReference(storageQueues, "AzureQueues")
.WithEnvironment("ConnectionStrings:Email", SharedEmailConnectionString)
.WithEnvironment("EX_ExceptionlessApiKey", builder.Configuration["ExceptionlessApiKey"])
.WithEnvironment("EX_ExceptionlessServerUrl", api.GetEndpoint("http"))
.WaitFor(api)
.WaitFor(elastic)
.WaitFor(cache)
.WaitFor(mail)
Expand Down Expand Up @@ -192,7 +198,9 @@
.WithBrowserLogs()
.WithReference(api)
.WithReference(oldApp)
.WithEnvironment("PUBLIC_EXCEPTIONLESS_API_KEY", builder.Configuration["PUBLIC_EXCEPTIONLESS_API_KEY"])
.WithEnvironment("PUBLIC_EXCEPTIONLESS_SERVER_URL", exceptionlessServerUrl)
.WithEnvironment("PUBLIC_EXCEPTIONLESS_TELEMETRY_SERVER_URL", builder.Configuration["PUBLIC_EXCEPTIONLESS_TELEMETRY_SERVER_URL"] ?? String.Empty)
.WithEnvironment("PORT", appPort.ToString())
.WithEndpoint("http", e =>
{
Expand All @@ -203,6 +211,7 @@
e.IsProxied = false;
})
.WithHttpsDeveloperCertificate()
.WaitFor(api)
.WithUrlForEndpoint("http", u =>
{
u.DisplayText = "Open App";
Expand Down
2 changes: 2 additions & 0 deletions src/Exceptionless.AppHost/appsettings.Development.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
{
"ExceptionlessApiKey": "Bx7JgglstPG544R34Tw9T7RlCed3OIwtYXVeyhT2",
"PUBLIC_EXCEPTIONLESS_API_KEY": "Bx7JgglstPG544R34Tw9T7RlCed3OIwtYXVeyhT2",
"Logging": {
"LogLevel": {
"Default": "Information",
Expand Down
2 changes: 2 additions & 0 deletions src/Exceptionless.Core/Models/SystemSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ public sealed class SystemSettings : IIdentity, IHaveDates

public bool? AssistantEnabled { get; set; }

public bool AssistantConversationSharingDefaultEnabled { get; set; }

public bool? EventSubmissionEnabled { get; set; }

public SystemNotification? SystemNotification { get; set; }
Expand Down
1 change: 1 addition & 0 deletions src/Exceptionless.Core/Models/User.cs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ public record User : IIdentity, IHaveDates, IValidatableObject
public string? AvatarFileName { get; set; }

public bool EmailNotificationsEnabled { get; set; } = true;
public bool? AssistantConversationSharingEnabled { get; set; }
public bool IsEmailAddressVerified { get; set; }
public string? VerifyEmailAddressToken { get; set; }
public DateTime VerifyEmailAddressTokenExpiration { get; set; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ public override void ConfigureIndexMapping(TypeMappingDescriptor<SystemSettings>
.SetupDefaults()
.Keyword(settings => settings.AssistantModel)
.Boolean(settings => settings.AssistantEnabled)
.Boolean(settings => settings.AssistantConversationSharingDefaultEnabled)
.Boolean(settings => settings.EventSubmissionEnabled)
.Object(settings => settings.SystemNotification, notification => notification.Properties(properties => properties
.Date("date")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ public override void ConfigureIndexMapping(TypeMappingDescriptor<User> map)
.Text(e => e.FullName, t => t.AddKeywordField())
.Text(e => e.EmailAddress, t => t.Analyzer(KEYWORD_LOWERCASE_ANALYZER).AddKeywordField())
.Boolean(e => e.IsEmailAddressVerified)
.Boolean(e => e.AssistantConversationSharingEnabled)
.Keyword(e => e.VerifyEmailAddressToken)
.Date(e => e.VerifyEmailAddressTokenExpiration)
.Keyword(e => e.PasswordResetToken)
Expand Down
6 changes: 6 additions & 0 deletions src/Exceptionless.Core/Services/SystemSettingsService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -92,4 +92,10 @@ public async Task<bool> IsEventSubmissionEnabledAsync()
var settings = await _getSettingsAsync();
return settings?.EventSubmissionEnabled ?? !_appOptions.EventSubmissionDisabled;
}

public async Task<bool> IsAssistantConversationSharingDefaultEnabledAsync()
{
var settings = await _getSettingsAsync();
return settings?.AssistantConversationSharingDefaultEnabled ?? false;
}
}
4 changes: 4 additions & 0 deletions src/Exceptionless.Core/Utility/AppDiagnostics.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ public static class AppDiagnostics
internal static readonly AssemblyName AssemblyName = typeof(AppDiagnostics).Assembly.GetName();
internal static readonly string? AssemblyVersion = typeof(AppDiagnostics).Assembly.GetCustomAttribute<AssemblyInformationalVersionAttribute>()?.InformationalVersion ?? AssemblyName.Version?.ToString();
internal static readonly ActivitySource ActivitySource = new(AssemblyName.Name ?? "Exceptionless", AssemblyVersion);
internal static readonly ActivitySource AssistantActivitySource = new("Exceptionless.Assistant", AssemblyVersion);
internal static readonly Meter Meter = new("Exceptionless", AssemblyVersion);
private static readonly string _metricsPrefix = "ex.";

Expand Down Expand Up @@ -91,6 +92,9 @@ public GaugeInfo(Meter meter, string name)
internal static readonly Counter<int> EventsSubmitted = Meter.CreateCounter<int>("ex.events.submitted", description: "Events submitted to the pipeline to be processed");
internal static readonly Counter<long> AssistantTurns = Meter.CreateCounter<long>("ex.assistant.turns", description: "Assistant turns accepted");
internal static readonly Counter<long> AssistantTurnOutcomes = Meter.CreateCounter<long>("ex.assistant.turn.outcomes", description: "Assistant turn outcomes");
internal static readonly Histogram<double> AssistantTurnDuration = Meter.CreateHistogram<double>("ex.assistant.turn.duration", unit: "ms", description: "Assistant turn duration by outcome and failure reason");
internal static readonly Histogram<double> AssistantProviderDuration = Meter.CreateHistogram<double>("ex.assistant.provider.duration", unit: "ms", description: "Assistant provider request duration by outcome");
internal static readonly Histogram<double> AssistantToolDuration = Meter.CreateHistogram<double>("ex.assistant.tool.duration", unit: "ms", description: "Assistant tool duration by tool and outcome");
internal static readonly Counter<long> AssistantTurnsBlocked = Meter.CreateCounter<long>("ex.assistant.turns.blocked", description: "Assistant turns blocked by a usage limit");
internal static readonly Counter<long> AssistantProviderRequests = Meter.CreateCounter<long>("ex.assistant.provider.requests", description: "Assistant provider requests");
internal static readonly Counter<long> AssistantToolCalls = Meter.CreateCounter<long>("ex.assistant.tool.calls", description: "Assistant tool calls");
Expand Down
2 changes: 1 addition & 1 deletion src/Exceptionless.Core/Utility/SampleDataService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,7 @@ public async Task CreateInternalOrganizationAndProjectAsync(string userId)
var project = new Project
{
Id = INTERNAL_PROJECT_ID,
Name = "API",
Name = "Exceptionless",
OrganizationId = organization.Id,
NextSummaryEndOfDayTicks = _timeProvider.GetUtcNow().UtcDateTime.Date.AddDays(1).AddHours(1).Ticks
};
Expand Down
12 changes: 12 additions & 0 deletions src/Exceptionless.Web/Api/Endpoints/AdminEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,18 @@ public static IEndpointRouteBuilder MapAdminEndpoints(this IEndpointRouteBuilder
.WithTags(nameof(AdminEndpoints))
.WithSummary("Update Exie assistant availability");

endpoints.MapPut("api/v2/admin/assistant-settings/conversation-sharing", async (HttpContext httpContext, [FromBody] UpdateAssistantConversationSharingSettings request, AssistantModelSettingsService settingsService)
=> HttpResults.Ok(await settingsService.SetConversationSharingDefaultEnabledAsync(request.Enabled, httpContext.Request.GetUser().Id)))
.RequireAuthorization(AuthorizationRoles.GlobalAdminPolicy)
.AddEndpointFilter<AutoValidationEndpointFilter>()
.Accepts<UpdateAssistantConversationSharingSettings>("application/json", "application/*+json")
.Produces<AssistantModelSettings>(StatusCodes.Status200OK)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status403Forbidden)
.ProducesProblem(StatusCodes.Status422UnprocessableEntity)
.WithTags(nameof(AdminEndpoints))
.WithSummary("Update Exie full conversation logging");

endpoints.MapGet("api/v2/admin/event-submission-settings", GetEventSubmissionSettingsAsync)
.RequireAuthorization(AuthorizationRoles.GlobalAdminPolicy)
.Produces<EventSubmissionSettings>(StatusCodes.Status200OK)
Expand Down
88 changes: 81 additions & 7 deletions src/Exceptionless.Web/Api/Endpoints/AssistantEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,27 @@ namespace Exceptionless.Web.Api.Endpoints;

public static class AssistantEndpoints
{
internal const string FullLoggingHeaderName = "X-Exie-Full-Logging";
private static readonly JsonSerializerOptions s_jsonOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web).ConfigureExceptionlessApiDefaults();

public static IEndpointRouteBuilder MapAssistantEndpoints(this IEndpointRouteBuilder endpoints)
{
endpoints.MapGet("api/v2/assistant/conversation-sharing", GetConversationSharingAsync)
.WithName("GetAssistantConversationSharing")
.RequireAuthorization(AuthorizationRoles.UserPolicy)
.Produces<AssistantConversationSharingSettings>()
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status404NotFound);

endpoints.MapPut("api/v2/assistant/conversation-sharing", SetConversationSharingAsync)
.WithName("SetAssistantConversationSharing")
.WithDescription("Saves the current user's choice. Null follows the admin default; true and false remain explicit choices when the default changes.")
.RequireAuthorization(AuthorizationRoles.UserPolicy)
.Produces<AssistantConversationSharingSettings>()
.Produces(StatusCodes.Status400BadRequest)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status404NotFound);

endpoints.MapGet("api/v2/assistant/access", GetAccessAsync)
.WithName("GetAssistantAccess")
.RequireAuthorization(AuthorizationRoles.UserPolicy)
Expand All @@ -26,6 +43,7 @@ public static IEndpointRouteBuilder MapAssistantEndpoints(this IEndpointRouteBui

endpoints.MapPost("api/v2/assistant/chat", StreamChatAsync)
.WithName("StreamAssistantChat")
.WithDescription("The X-Exie-Full-Logging response header indicates whether the browser should record prompts and responses for this turn. Missing or false disables conversation logging.")
.RequireAuthorization(AuthorizationRoles.UserPolicy)
.WithMetadata(new RequestSizeLimitAttribute(256 * 1024))
.Produces(StatusCodes.Status200OK, contentType: "application/x-ndjson")
Expand All @@ -46,6 +64,7 @@ private static async Task<IResult> StreamChatAsync(
AssistantAccessService assistantAccessService,
AssistantUsageService assistantUsageService,
AssistantService assistantService,
AssistantConversationSharingService conversationSharingService,
TimeProvider timeProvider,
ILogger<AssistantService> logger)
{
Expand Down Expand Up @@ -74,6 +93,7 @@ private static async Task<IResult> StreamChatAsync(
OrganizationId = organizationId,
ConversationId = conversationId ?? Guid.NewGuid().ToString("N")
};
bool fullLoggingEnabled = (await conversationSharingService.GetAsync(userId))?.Enabled == true;
var planOptions = access.PlanOptions!;
await using var turnReservation = await assistantUsageService.TryStartTurnAsync(organizationId, planOptions);
if (!turnReservation.Allowed)
Expand All @@ -99,17 +119,39 @@ private static async Task<IResult> StreamChatAsync(

using var turnCancellationSource = CancellationTokenSource.CreateLinkedTokenSource(httpContext.RequestAborted);
turnCancellationSource.CancelAfter(TimeSpan.FromSeconds(AssistantLimits.MaximumTurnDurationSeconds));
using var diagnostics = new AssistantTurnDiagnostics(logger, timeProvider, organizationId!, request.ConversationId!, httpContext.TraceIdentifier, httpContext.RequestAborted);
var response = assistantService.StreamAsync(request, userId, planOptions, diagnostics, turnCancellationSource.Token);
await WriteResponseAsync(httpContext, response, assistantUsageService, organizationId!, diagnostics, turnCancellationSource.Token, fullLoggingEnabled);

return HttpResults.Empty;
}

internal static async Task WriteResponseAsync(
HttpContext httpContext,
IAsyncEnumerable<AssistantStreamEvent> response,
AssistantUsageService assistantUsageService,
string organizationId,
AssistantTurnDiagnostics diagnostics,
CancellationToken cancellationToken,
bool fullLoggingEnabled = false)
{
httpContext.Response.Headers[FullLoggingHeaderName] = fullLoggingEnabled ? "true" : "false";
bool responseFailed = false;
try
{
await foreach (var item in assistantService.StreamAsync(request, userId, planOptions, turnCancellationSource.Token))
await foreach (var item in response.WithCancellation(cancellationToken))
{
diagnostics.Observe(item);
responseFailed |= item.Type == "error";
await JsonSerializer.SerializeAsync(httpContext.Response.Body, item, s_jsonOptions, turnCancellationSource.Token);
await httpContext.Response.WriteAsync("\n", turnCancellationSource.Token);
await httpContext.Response.Body.FlushAsync(turnCancellationSource.Token);
string stage = diagnostics.Stage;
diagnostics.Stage = "response_write";
await JsonSerializer.SerializeAsync(httpContext.Response.Body, item, s_jsonOptions, cancellationToken);
await httpContext.Response.WriteAsync("\n", cancellationToken);
await httpContext.Response.Body.FlushAsync(cancellationToken);
diagnostics.Stage = stage;
}

diagnostics.Finish(responseFailed ? "failed" : "completed");
if (responseFailed)
await assistantUsageService.RecordTurnFailedAsync(organizationId);
else
Expand All @@ -118,25 +160,37 @@ private static async Task<IResult> StreamChatAsync(
catch (OperationCanceledException) when (httpContext.RequestAborted.IsCancellationRequested)
{
// The browser closing or stopping the stream is expected.
diagnostics.Finish("cancelled", "client_disconnected");
await assistantUsageService.RecordTurnCancelledAsync(organizationId);
}
catch (OperationCanceledException)
{
string failureCode = cancellationToken.IsCancellationRequested ? "turn_timeout"
: diagnostics.Stage is "provider_request" or "provider_stream" ? "provider_timeout" : "operation_cancelled";
diagnostics.Finish("failed", failureCode);
await assistantUsageService.RecordTurnFailedAsync(organizationId);
var error = AssistantStreamEvent.Error("Exie took too long to complete this response. Try narrowing the question.");
await JsonSerializer.SerializeAsync(httpContext.Response.Body, error, s_jsonOptions, CancellationToken.None);
await httpContext.Response.WriteAsync("\n", CancellationToken.None);
}
catch (Exception ex)
{
string failureCode = ex switch
{
AssistantProviderException providerException => providerException.FailureCode,
_ when diagnostics.Stage == "response_write" => "response_write_error",
_ when diagnostics.Stage == "tool_execution" => "tool_execution_error",
HttpRequestException when diagnostics.Stage is "provider_request" or "provider_stream" => "provider_transport_error",
JsonException when diagnostics.Stage == "provider_stream" => "invalid_provider_response",
IOException when diagnostics.Stage == "provider_stream" => "provider_stream_error",
_ => "internal_error"
};
diagnostics.Finish("failed", failureCode, ex);
await assistantUsageService.RecordTurnFailedAsync(organizationId);
logger.LogError(ex, "Unable to stream an in-app assistant response");
var error = AssistantStreamEvent.Error(ex is AssistantProviderException ? ex.Message : "Exie could not complete this request.");
await JsonSerializer.SerializeAsync(httpContext.Response.Body, error, s_jsonOptions, CancellationToken.None);
await httpContext.Response.WriteAsync("\n", CancellationToken.None);
}

return HttpResults.Empty;
}

private static async Task<IResult> GetAccessAsync(
Expand All @@ -148,6 +202,26 @@ private static async Task<IResult> GetAccessAsync(
return HttpResults.Ok(access.ToResponse());
}

private static async Task<IResult> GetConversationSharingAsync(HttpContext httpContext, AssistantConversationSharingService service)
{
string? userId = httpContext.User.GetUserId();
if (String.IsNullOrWhiteSpace(userId))
return HttpResults.Unauthorized();

var settings = await service.GetAsync(userId);
return settings is null ? HttpResults.NotFound() : HttpResults.Ok(settings);
}

private static async Task<IResult> SetConversationSharingAsync(UpdateAssistantConversationSharing request, HttpContext httpContext, AssistantConversationSharingService service)
{
string? userId = httpContext.User.GetUserId();
if (String.IsNullOrWhiteSpace(userId))
return HttpResults.Unauthorized();

var settings = await service.SetAsync(userId, request.Enabled);
return settings is null ? HttpResults.NotFound() : HttpResults.Ok(settings);
}

internal static IResult? MapAccessFailure(AssistantAccessDecision access) => access.Reason switch
{
AssistantAccessReason.Available => null,
Expand Down
2 changes: 1 addition & 1 deletion src/Exceptionless.Web/ApmExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ public static IHostBuilder AddApm(this IHostBuilder builder, ApmConfig config)
});

b.AddHttpClientInstrumentation();
b.AddSource("Exceptionless", "Foundatio");
b.AddSource("Exceptionless", "Exceptionless.Assistant", "Foundatio");

if (config.EnableRedis)
b.AddRedisInstrumentation(c =>
Expand Down
Loading
Loading