Compare commits
1 Commits
7c6dacdbd8
...
Features/D
| Author | SHA1 | Date | |
|---|---|---|---|
|
f6dfc1363f
|
15
.gitignore
vendored
15
.gitignore
vendored
@@ -4,6 +4,21 @@
|
||||
##
|
||||
## Get latest from https://github.com/github/gitignore/blob/main/VisualStudio.gitignore
|
||||
|
||||
#Kevins project namager :) - trace [934875333]
|
||||
.tmp/
|
||||
.manifest/
|
||||
Monospace/
|
||||
VMspace/
|
||||
Sharedspace/
|
||||
Graphene/
|
||||
Graph2Code-Jibo
|
||||
Shovel-netProj
|
||||
Shoveled-Jibo-Cloud
|
||||
Shoveled-Jibo-Cloud-OpenMemory
|
||||
latest.ShovelDump
|
||||
|
||||
|
||||
|
||||
# User-specific files
|
||||
*.rsuser
|
||||
*.suo
|
||||
|
||||
86
JiboExperiments.sln
Normal file
86
JiboExperiments.sln
Normal file
@@ -0,0 +1,86 @@
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.5.2.0
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "OpenJibo", "OpenJibo", "{2FDD1CD9-89DA-D176-F85D-DC517FF08BF4}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{9CD502EA-259A-A102-F54F-DB66ECB43CCA}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jibo.Runtime.Abstractions", "OpenJibo\src\Jibo.Runtime.Abstractions\Jibo.Runtime.Abstractions.csproj", "{4EC1F8A2-7A15-79FC-2A37-9620624156F8}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Playground", "OpenJibo\src\Playground\Playground.csproj", "{61A125DD-6776-6FF9-D0B9-9945ADBCC0E1}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{C6EF17FD-82CB-6C4D-B0EB-AB57E442D309}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jibo.Cloud.Tests", "OpenJibo\tests\Jibo.Cloud.Tests\Jibo.Cloud.Tests.csproj", "{C18A6AEA-FD8E-FDAF-1589-0BC2EF6C8F46}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Jibo.Cloud", "Jibo.Cloud", "{1E709A93-6AAE-CBDE-D98F-8B1F8D079AE6}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "dotnet", "dotnet", "{7A0D8E3B-15D1-0621-86F9-1CAFD1E26384}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{42A75C5C-1B56-2C7E-5D8B-C570665075F4}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jibo.Cloud.Api", "OpenJibo\src\Jibo.Cloud\dotnet\src\Jibo.Cloud.Api\Jibo.Cloud.Api.csproj", "{888E2B18-7919-73EF-DF00-AD1A4EA157FF}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jibo.Cloud.Application", "OpenJibo\src\Jibo.Cloud\dotnet\src\Jibo.Cloud.Application\Jibo.Cloud.Application.csproj", "{EEDE5906-13C3-E9FB-0AFB-27376A77F1AD}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jibo.Cloud.Domain", "OpenJibo\src\Jibo.Cloud\dotnet\src\Jibo.Cloud.Domain\Jibo.Cloud.Domain.csproj", "{6B4AD66C-CACD-D9D6-4803-33A5DB0C7F4C}"
|
||||
EndProject
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jibo.Cloud.Infrastructure", "OpenJibo\src\Jibo.Cloud\dotnet\src\Jibo.Cloud.Infrastructure\Jibo.Cloud.Infrastructure.csproj", "{5BD9420F-7E77-81A2-713B-8FDBF17C2D6E}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Release|Any CPU = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{4EC1F8A2-7A15-79FC-2A37-9620624156F8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{4EC1F8A2-7A15-79FC-2A37-9620624156F8}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{4EC1F8A2-7A15-79FC-2A37-9620624156F8}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{4EC1F8A2-7A15-79FC-2A37-9620624156F8}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{61A125DD-6776-6FF9-D0B9-9945ADBCC0E1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{61A125DD-6776-6FF9-D0B9-9945ADBCC0E1}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{61A125DD-6776-6FF9-D0B9-9945ADBCC0E1}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{61A125DD-6776-6FF9-D0B9-9945ADBCC0E1}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{C18A6AEA-FD8E-FDAF-1589-0BC2EF6C8F46}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{C18A6AEA-FD8E-FDAF-1589-0BC2EF6C8F46}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{C18A6AEA-FD8E-FDAF-1589-0BC2EF6C8F46}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{C18A6AEA-FD8E-FDAF-1589-0BC2EF6C8F46}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{888E2B18-7919-73EF-DF00-AD1A4EA157FF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{888E2B18-7919-73EF-DF00-AD1A4EA157FF}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{888E2B18-7919-73EF-DF00-AD1A4EA157FF}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{888E2B18-7919-73EF-DF00-AD1A4EA157FF}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{EEDE5906-13C3-E9FB-0AFB-27376A77F1AD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{EEDE5906-13C3-E9FB-0AFB-27376A77F1AD}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{EEDE5906-13C3-E9FB-0AFB-27376A77F1AD}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{EEDE5906-13C3-E9FB-0AFB-27376A77F1AD}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{6B4AD66C-CACD-D9D6-4803-33A5DB0C7F4C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{6B4AD66C-CACD-D9D6-4803-33A5DB0C7F4C}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{6B4AD66C-CACD-D9D6-4803-33A5DB0C7F4C}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{6B4AD66C-CACD-D9D6-4803-33A5DB0C7F4C}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{5BD9420F-7E77-81A2-713B-8FDBF17C2D6E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{5BD9420F-7E77-81A2-713B-8FDBF17C2D6E}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{5BD9420F-7E77-81A2-713B-8FDBF17C2D6E}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{5BD9420F-7E77-81A2-713B-8FDBF17C2D6E}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
EndGlobalSection
|
||||
GlobalSection(NestedProjects) = preSolution
|
||||
{9CD502EA-259A-A102-F54F-DB66ECB43CCA} = {2FDD1CD9-89DA-D176-F85D-DC517FF08BF4}
|
||||
{4EC1F8A2-7A15-79FC-2A37-9620624156F8} = {9CD502EA-259A-A102-F54F-DB66ECB43CCA}
|
||||
{61A125DD-6776-6FF9-D0B9-9945ADBCC0E1} = {9CD502EA-259A-A102-F54F-DB66ECB43CCA}
|
||||
{C6EF17FD-82CB-6C4D-B0EB-AB57E442D309} = {2FDD1CD9-89DA-D176-F85D-DC517FF08BF4}
|
||||
{C18A6AEA-FD8E-FDAF-1589-0BC2EF6C8F46} = {C6EF17FD-82CB-6C4D-B0EB-AB57E442D309}
|
||||
{1E709A93-6AAE-CBDE-D98F-8B1F8D079AE6} = {9CD502EA-259A-A102-F54F-DB66ECB43CCA}
|
||||
{7A0D8E3B-15D1-0621-86F9-1CAFD1E26384} = {1E709A93-6AAE-CBDE-D98F-8B1F8D079AE6}
|
||||
{42A75C5C-1B56-2C7E-5D8B-C570665075F4} = {7A0D8E3B-15D1-0621-86F9-1CAFD1E26384}
|
||||
{888E2B18-7919-73EF-DF00-AD1A4EA157FF} = {42A75C5C-1B56-2C7E-5D8B-C570665075F4}
|
||||
{EEDE5906-13C3-E9FB-0AFB-27376A77F1AD} = {42A75C5C-1B56-2C7E-5D8B-C570665075F4}
|
||||
{6B4AD66C-CACD-D9D6-4803-33A5DB0C7F4C} = {42A75C5C-1B56-2C7E-5D8B-C570665075F4}
|
||||
{5BD9420F-7E77-81A2-713B-8FDBF17C2D6E} = {42A75C5C-1B56-2C7E-5D8B-C570665075F4}
|
||||
EndGlobalSection
|
||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||
SolutionGuid = {E18C2B78-D343-47FC-9314-42977AE46261}
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
22
OpenJibo/docs/logging.md
Normal file
22
OpenJibo/docs/logging.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# Logging argument!
|
||||
- - -
|
||||
|
||||
using the new `DetailedOperationLogger` class you can do tiered logging , from level 1 -10
|
||||
|
||||
you can `LogStep` at any level, and it will only log if the log level is 4+
|
||||
`logstate` at any level, and it will only log if the log level is 5+ (state tracking)
|
||||
`logDecision` at any level, and it will only log if the log level is 3+ (decision points)
|
||||
`logTiming` at any level, and it will only log if the log level is 6= (timing performance metrics)
|
||||
`logPayload` at any level, and it will only log if the log level is 8+ (payload data)
|
||||
`logExternalCall` at any level, and it will only log if the log level is 5+ (external service calls)
|
||||
`LogMatch` at any level, and it will only log if the log level is 4+ (pattern matching)
|
||||
|
||||
|
||||
i didnt touch the existing logging but its easy to implement the new logging system in the existing code
|
||||
|
||||
you can see implementations at:
|
||||
- OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Telemetry/FileWebSocketTelemetrySink.cs
|
||||
- OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Infrastructure/Telemetry/FileWebSocketTelemetrySink.cs
|
||||
|
||||
the parser is also inside :
|
||||
`OpenJibo/src/Jibo.Cloud/dotnet/src/Jibo.Cloud.Api/Logging/LogLevelConfigurator.cs`
|
||||
@@ -0,0 +1,72 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jibo.Cloud.Api.Logging;
|
||||
|
||||
/// <summary>
|
||||
/// Configures logging levels based on command-line arguments.
|
||||
/// Higher log values = more verbose logging.
|
||||
/// </summary>
|
||||
public static class LogLevelConfigurator
|
||||
{
|
||||
/// <summary>
|
||||
/// Parses the log level from command-line arguments (format: log=N where N is 0-10).
|
||||
/// Returns null if no log argument is found.
|
||||
/// </summary>
|
||||
public static int? ParseLogLevelFromArgs(string[] args)
|
||||
{
|
||||
foreach (var arg in args)
|
||||
{
|
||||
if (arg.StartsWith("log=", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var value = arg["log=".Length..];
|
||||
if (int.TryParse(value, out var level) && level >= 0)
|
||||
{
|
||||
return Math.Min(level, 10);
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configures logging level based on the numeric intensity (0-10).
|
||||
/// Higher values enable more verbose logging.
|
||||
/// </summary>
|
||||
public static void ConfigureLogging(WebApplicationBuilder builder, int logLevel)
|
||||
{
|
||||
builder.Logging.ClearProviders();
|
||||
builder.Logging.AddConsole();
|
||||
builder.Logging.AddDebug();
|
||||
|
||||
var level = MapToLogLevel(logLevel);
|
||||
|
||||
builder.Logging.SetMinimumLevel(level);
|
||||
|
||||
builder.Logging.AddFilter("Microsoft.AspNetCore", logLevel >= 8 ? LogLevel.Debug : LogLevel.Warning);
|
||||
builder.Logging.AddFilter("Microsoft.Hosting", logLevel >= 7 ? LogLevel.Information : LogLevel.Warning);
|
||||
builder.Logging.AddFilter("System", logLevel >= 9 ? LogLevel.Debug : LogLevel.Warning);
|
||||
|
||||
builder.Logging.AddFilter("Jibo.Cloud", logLevel >= 5 ? LogLevel.Debug : LogLevel.Information);
|
||||
builder.Logging.AddFilter("Jibo.Cloud.Application", logLevel >= 3 ? LogLevel.Debug : LogLevel.Information);
|
||||
builder.Logging.AddFilter("Jibo.Cloud.Infrastructure", logLevel >= 4 ? LogLevel.Debug : LogLevel.Information);
|
||||
}
|
||||
|
||||
private static LogLevel MapToLogLevel(int value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
0 => LogLevel.Error,
|
||||
1 => LogLevel.Warning,
|
||||
2 => LogLevel.Warning,
|
||||
3 => LogLevel.Information,
|
||||
4 => LogLevel.Information,
|
||||
5 => LogLevel.Information,
|
||||
6 => LogLevel.Debug,
|
||||
7 => LogLevel.Debug,
|
||||
8 => LogLevel.Debug,
|
||||
9 => LogLevel.Trace,
|
||||
10 => LogLevel.Trace,
|
||||
_ => LogLevel.Information
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Net.WebSockets;
|
||||
using System.Text;
|
||||
using Jibo.Cloud.Api.Logging;
|
||||
using Jibo.Cloud.Application.Abstractions;
|
||||
using Jibo.Cloud.Application.Services;
|
||||
using Jibo.Cloud.Domain.Models;
|
||||
@@ -7,7 +8,13 @@ using Jibo.Cloud.Infrastructure.DependencyInjection;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Services.AddOpenJiboCloud(builder.Configuration);
|
||||
var logLevel = LogLevelConfigurator.ParseLogLevelFromArgs(args);
|
||||
if (logLevel.HasValue)
|
||||
{
|
||||
LogLevelConfigurator.ConfigureLogging(builder, logLevel.Value);
|
||||
}
|
||||
|
||||
builder.Services.AddOpenJiboCloud(builder.Configuration, logLevel);
|
||||
|
||||
var app = builder.Build();
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="8.0.0" />
|
||||
<ProjectReference Include="..\Jibo.Cloud.Domain\Jibo.Cloud.Domain.csproj" />
|
||||
<ProjectReference Include="..\..\..\..\Jibo.Runtime.Abstractions\Jibo.Runtime.Abstractions.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jibo.Cloud.Application.Logging;
|
||||
|
||||
/// <summary>
|
||||
/// Provides detailed operation logging that activates based on log intensity level.
|
||||
/// Higher log levels = more detailed logging.
|
||||
/// </summary>
|
||||
public sealed class DetailedOperationLogger
|
||||
{
|
||||
private readonly ILogger _logger;
|
||||
private readonly int _configuredLogLevel;
|
||||
|
||||
public DetailedOperationLogger(ILogger logger, int? configuredLogLevel = null)
|
||||
{
|
||||
_logger = logger;
|
||||
_configuredLogLevel = configuredLogLevel ?? 3;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Log method entry at Debug level when log level >= 3
|
||||
/// </summary>
|
||||
public void LogEntry(string methodName, params (string Key, object? Value)[] parameters)
|
||||
{
|
||||
if (_configuredLogLevel < 3) return;
|
||||
|
||||
if (_logger.IsEnabled(LogLevel.Debug))
|
||||
{
|
||||
var paramStr = parameters.Length > 0
|
||||
? string.Join(", ", parameters.Select(p => $"{p.Key}={p.Value}"))
|
||||
: "none";
|
||||
_logger.LogDebug("[ENTRY] {MethodName}({Parameters})", methodName, paramStr);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Log method exit at Debug level when log level >= 3
|
||||
/// </summary>
|
||||
public void LogExit(string methodName, string? result = null)
|
||||
{
|
||||
if (_configuredLogLevel < 3) return;
|
||||
|
||||
if (_logger.IsEnabled(LogLevel.Debug))
|
||||
{
|
||||
var resultStr = result ?? "void";
|
||||
_logger.LogDebug("[EXIT] {MethodName} -> {Result}", methodName, resultStr);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Log a detailed operation step at Debug level when log level >= 4
|
||||
/// </summary>
|
||||
public void LogStep(string operation, string step, string? details = null)
|
||||
{
|
||||
if (_configuredLogLevel < 4) return;
|
||||
|
||||
if (_logger.IsEnabled(LogLevel.Debug))
|
||||
{
|
||||
var detailStr = details != null ? $" | {details}" : "";
|
||||
_logger.LogDebug("[STEP] {Operation}.{Step}{Details}", operation, step, detailStr);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Log state information at Debug level when log level >= 5
|
||||
/// </summary>
|
||||
public void LogState(string context, string stateName, object? value)
|
||||
{
|
||||
if (_configuredLogLevel < 5) return;
|
||||
|
||||
if (_logger.IsEnabled(LogLevel.Debug))
|
||||
{
|
||||
_logger.LogDebug("[STATE] {Context}.{StateName} = {Value}", context, stateName, value);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Log decision information at Information level when log level >= 3
|
||||
/// </summary>
|
||||
public void LogDecision(string context, string decision, string? reason = null)
|
||||
{
|
||||
if (_configuredLogLevel < 3) return;
|
||||
|
||||
if (_logger.IsEnabled(LogLevel.Information))
|
||||
{
|
||||
var reasonStr = reason != null ? $" (reason: {reason})" : "";
|
||||
_logger.LogInformation("[DECISION] {Context}: {Decision}{Reason}", context, decision, reasonStr);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Log performance timing at Debug level when log level >= 6
|
||||
/// </summary>
|
||||
public void LogTiming(string operation, long elapsedMs)
|
||||
{
|
||||
if (_configuredLogLevel < 6) return;
|
||||
|
||||
if (_logger.IsEnabled(LogLevel.Debug))
|
||||
{
|
||||
_logger.LogDebug("[TIMING] {Operation} completed in {ElapsedMs}ms", operation, elapsedMs);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Log data payload at Trace level when log level >= 8
|
||||
/// </summary>
|
||||
public void LogPayload(string context, string dataType, int dataSize, string? preview = null)
|
||||
{
|
||||
if (_configuredLogLevel < 8) return;
|
||||
|
||||
if (_logger.IsEnabled(LogLevel.Trace))
|
||||
{
|
||||
var previewStr = preview != null ? $" preview: {preview}" : "";
|
||||
_logger.LogTrace("[PAYLOAD] {Context} {DataType} size={Size}{Preview}", context, dataType, dataSize, previewStr);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Log external call at Debug level when log level >= 5
|
||||
/// </summary>
|
||||
public void LogExternalCall(string service, string operation, string? details = null)
|
||||
{
|
||||
if (_configuredLogLevel < 5) return;
|
||||
|
||||
if (_logger.IsEnabled(LogLevel.Debug))
|
||||
{
|
||||
var detailStr = details != null ? $" ({details})" : "";
|
||||
_logger.LogDebug("[EXTERNAL] {Service}.{Operation}{Details}", service, operation, detailStr);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Log match/pattern information at Debug level when log level >= 4
|
||||
/// </summary>
|
||||
public void LogMatch(string context, string pattern, string input, bool matched)
|
||||
{
|
||||
if (_configuredLogLevel < 4) return;
|
||||
|
||||
if (_logger.IsEnabled(LogLevel.Debug))
|
||||
{
|
||||
_logger.LogDebug("[MATCH] {Context}: Pattern '{Pattern}' against '{Input}' => {Result}",
|
||||
context, pattern, input, matched ? "MATCHED" : "NO MATCH");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,16 @@
|
||||
using System.Text.Json;
|
||||
using Jibo.Cloud.Application.Abstractions;
|
||||
using Jibo.Cloud.Application.Logging;
|
||||
using Jibo.Cloud.Domain.Models;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jibo.Cloud.Application.Services;
|
||||
|
||||
public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
|
||||
public sealed class JiboCloudProtocolService(
|
||||
ICloudStateStore stateStore,
|
||||
ILogger<JiboCloudProtocolService> logger)
|
||||
{
|
||||
private readonly DetailedOperationLogger _detailedLogger = new(logger);
|
||||
private static readonly string[] AcceptedHosts =
|
||||
[
|
||||
"api.jibo.com",
|
||||
@@ -16,16 +21,25 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
|
||||
|
||||
public Task<ProtocolDispatchResult> DispatchAsync(ProtocolEnvelope envelope, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_detailedLogger.LogEntry(nameof(DispatchAsync),
|
||||
("method", envelope.Method),
|
||||
("path", envelope.Path),
|
||||
("host", envelope.HostName),
|
||||
("servicePrefix", envelope.ServicePrefix),
|
||||
("operation", envelope.Operation));
|
||||
|
||||
if (envelope.Method.Equals("GET", StringComparison.OrdinalIgnoreCase) &&
|
||||
envelope.Path == "/" &&
|
||||
string.IsNullOrWhiteSpace(envelope.ServicePrefix))
|
||||
{
|
||||
_detailedLogger.LogExit(nameof(DispatchAsync), "NoContent");
|
||||
return Task.FromResult(ProtocolDispatchResult.NoContent());
|
||||
}
|
||||
|
||||
if (envelope.Method.Equals("GET", StringComparison.OrdinalIgnoreCase) &&
|
||||
envelope.Path.Equals("/health", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_detailedLogger.LogExit(nameof(DispatchAsync), "Health");
|
||||
return Task.FromResult(ProtocolDispatchResult.Ok(new { ok = true, host = envelope.HostName }));
|
||||
}
|
||||
|
||||
@@ -45,6 +59,8 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
|
||||
|
||||
if (!AcceptedHosts.Contains(envelope.HostName, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
_detailedLogger.LogDecision(nameof(DispatchAsync), "HostNotAccepted", envelope.HostName);
|
||||
_detailedLogger.LogExit(nameof(DispatchAsync), "NotAccepted");
|
||||
return Task.FromResult(ProtocolDispatchResult.Ok(new
|
||||
{
|
||||
ok = true,
|
||||
@@ -53,26 +69,32 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
|
||||
}));
|
||||
}
|
||||
|
||||
_detailedLogger.LogStep(nameof(DispatchAsync), "ServicePrefixResolved", $"prefix={envelope.ServicePrefix}, operation={envelope.Operation}");
|
||||
|
||||
var servicePrefix = envelope.ServicePrefix ?? string.Empty;
|
||||
var operation = envelope.Operation ?? string.Empty;
|
||||
|
||||
if (servicePrefix.StartsWith("Log_", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_detailedLogger.LogStep(nameof(DispatchAsync), "HandlerSelected", "Log");
|
||||
return Task.FromResult(HandleLog(operation, envelope));
|
||||
}
|
||||
|
||||
if (servicePrefix.StartsWith("Backup_", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_detailedLogger.LogStep(nameof(DispatchAsync), "HandlerSelected", "Backup");
|
||||
return Task.FromResult(HandleBackup(operation));
|
||||
}
|
||||
|
||||
if (servicePrefix.StartsWith("Account_", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_detailedLogger.LogStep(nameof(DispatchAsync), "HandlerSelected", "Account");
|
||||
return Task.FromResult(HandleAccount(operation, envelope));
|
||||
}
|
||||
|
||||
if (servicePrefix.StartsWith("Notification_", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_detailedLogger.LogStep(nameof(DispatchAsync), "HandlerSelected", "Notification");
|
||||
return Task.FromResult(HandleNotification(operation, envelope));
|
||||
}
|
||||
|
||||
@@ -98,6 +120,7 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
|
||||
|
||||
if (servicePrefix.StartsWith("Robot_", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_detailedLogger.LogStep(nameof(DispatchAsync), "HandlerSelected", "Robot");
|
||||
return Task.FromResult(HandleRobot(operation, envelope));
|
||||
}
|
||||
|
||||
@@ -106,6 +129,8 @@ public sealed class JiboCloudProtocolService(ICloudStateStore stateStore)
|
||||
return Task.FromResult(HandleUpdate(operation, envelope));
|
||||
}
|
||||
|
||||
_detailedLogger.LogDecision(nameof(DispatchAsync), "UnknownHandler", $"{servicePrefix}.{operation}");
|
||||
_detailedLogger.LogExit(nameof(DispatchAsync), "DefaultResponse");
|
||||
return Task.FromResult(ProtocolDispatchResult.Ok(new
|
||||
{
|
||||
ok = true,
|
||||
|
||||
@@ -1,19 +1,31 @@
|
||||
using Jibo.Cloud.Application.Abstractions;
|
||||
using Jibo.Cloud.Application.Logging;
|
||||
using Jibo.Runtime.Abstractions;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jibo.Cloud.Application.Services;
|
||||
|
||||
public sealed class JiboInteractionService(
|
||||
JiboExperienceContentCache contentCache,
|
||||
IJiboRandomizer randomizer)
|
||||
IJiboRandomizer randomizer,
|
||||
ILogger<JiboInteractionService> logger)
|
||||
{
|
||||
private readonly DetailedOperationLogger _detailedLogger = new(logger);
|
||||
public async Task<JiboInteractionDecision> BuildDecisionAsync(TurnContext turn, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_detailedLogger.LogEntry(nameof(BuildDecisionAsync),
|
||||
("transcript", turn.NormalizedTranscript ?? turn.RawTranscript),
|
||||
("inputMode", turn.InputMode),
|
||||
("sourceKind", turn.SourceKind));
|
||||
|
||||
var catalog = await contentCache.GetCatalogAsync(cancellationToken);
|
||||
var transcript = (turn.NormalizedTranscript ?? turn.RawTranscript ?? string.Empty).Trim();
|
||||
var lowered = transcript.ToLowerInvariant();
|
||||
|
||||
_detailedLogger.LogState(nameof(BuildDecisionAsync), "NormalizedTranscript", transcript);
|
||||
_detailedLogger.LogState(nameof(BuildDecisionAsync), "ClientIntent", turn.Attributes.TryGetValue("clientIntent", out var ci) ? ci : null);
|
||||
var referenceLocalTime = TryResolveReferenceLocalTime(turn);
|
||||
var clientIntent = turn.Attributes.TryGetValue("clientIntent", out var rawClientIntent)
|
||||
? rawClientIntent?.ToString()
|
||||
@@ -40,7 +52,10 @@ public sealed class JiboInteractionService(
|
||||
isYesNoTurn,
|
||||
isTimerValueTurn,
|
||||
isAlarmValueTurn);
|
||||
return semanticIntent switch
|
||||
|
||||
_detailedLogger.LogDecision(nameof(BuildDecisionAsync), "SemanticIntentResolved", semanticIntent);
|
||||
|
||||
var decision = semanticIntent switch
|
||||
{
|
||||
"joke" => BuildJokeDecision(catalog),
|
||||
"dance" => BuildRandomDanceDecision(catalog),
|
||||
@@ -85,6 +100,9 @@ public sealed class JiboInteractionService(
|
||||
"news" => BuildNewsDecision(catalog),
|
||||
_ => new JiboInteractionDecision("chat", BuildGenericReply(catalog, transcript, lowered))
|
||||
};
|
||||
|
||||
_detailedLogger.LogExit(nameof(BuildDecisionAsync), $"intent={decision.IntentName}, skill={decision.SkillName ?? "null"}");
|
||||
return decision;
|
||||
}
|
||||
|
||||
private JiboInteractionDecision BuildJokeDecision(JiboExperienceCatalog catalog)
|
||||
|
||||
@@ -1,53 +1,83 @@
|
||||
using System.Text.Json;
|
||||
using Jibo.Cloud.Application.Abstractions;
|
||||
using Jibo.Cloud.Application.Logging;
|
||||
using Jibo.Cloud.Domain.Models;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jibo.Cloud.Application.Services;
|
||||
|
||||
public sealed class JiboWebSocketService(
|
||||
ICloudStateStore stateStore,
|
||||
IWebSocketTelemetrySink telemetrySink,
|
||||
WebSocketTurnFinalizationService turnFinalizationService)
|
||||
WebSocketTurnFinalizationService turnFinalizationService,
|
||||
ILogger<JiboWebSocketService> logger)
|
||||
{
|
||||
private readonly DetailedOperationLogger _detailedLogger = new(logger);
|
||||
public CloudSession GetOrCreateSession(WebSocketMessageEnvelope envelope)
|
||||
{
|
||||
return stateStore.FindSessionByToken(envelope.Token ?? string.Empty) ??
|
||||
_detailedLogger.LogEntry(nameof(GetOrCreateSession),
|
||||
("token", envelope.Token),
|
||||
("kind", envelope.Kind),
|
||||
("host", envelope.HostName));
|
||||
|
||||
var session = stateStore.FindSessionByToken(envelope.Token ?? string.Empty) ??
|
||||
stateStore.OpenSession(envelope.Kind, null, envelope.Token, envelope.HostName, envelope.Path);
|
||||
|
||||
_detailedLogger.LogExit(nameof(GetOrCreateSession), $"sessionId={session.SessionId}");
|
||||
return session;
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<WebSocketReply>> HandleMessageAsync(WebSocketMessageEnvelope envelope, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_detailedLogger.LogEntry(nameof(HandleMessageAsync),
|
||||
("isBinary", envelope.IsBinary),
|
||||
("textLength", envelope.Text?.Length ?? 0),
|
||||
("binaryLength", envelope.Binary?.Length ?? 0));
|
||||
|
||||
var session = GetOrCreateSession(envelope);
|
||||
session.LastSeenUtc = DateTimeOffset.UtcNow;
|
||||
|
||||
_detailedLogger.LogState(nameof(HandleMessageAsync), "SessionId", session.SessionId);
|
||||
_detailedLogger.LogState(nameof(HandleMessageAsync), "SessionKind", session.Kind);
|
||||
|
||||
if (envelope.IsBinary)
|
||||
{
|
||||
_detailedLogger.LogStep(nameof(HandleMessageAsync), "ProcessingBinaryAudio", $"bytes={envelope.Binary?.Length ?? 0}");
|
||||
var replies = await turnFinalizationService.HandleBinaryAudioAsync(session, envelope, cancellationToken);
|
||||
await telemetrySink.RecordTurnEventAsync(envelope, session, "binary_audio_received", new Dictionary<string, object?>
|
||||
{
|
||||
["bytes"] = envelope.Binary?.Length ?? 0
|
||||
}, cancellationToken);
|
||||
_detailedLogger.LogPayload(nameof(HandleMessageAsync), "BinaryAudio", envelope.Binary?.Length ?? 0, null);
|
||||
_detailedLogger.LogExit(nameof(HandleMessageAsync), $"replies={replies.Count}");
|
||||
return replies;
|
||||
}
|
||||
|
||||
var parsedType = ReadMessageType(envelope.Text);
|
||||
_detailedLogger.LogDecision(nameof(HandleMessageAsync), "MessageTypeResolved", parsedType);
|
||||
|
||||
session.LastMessageType = parsedType;
|
||||
WebSocketTurnFinalizationService.ObserveIncomingMessage(session, envelope.Text);
|
||||
_detailedLogger.LogState(nameof(HandleMessageAsync), "LastMessageType", parsedType);
|
||||
|
||||
switch (parsedType)
|
||||
{
|
||||
case "CONTEXT":
|
||||
{
|
||||
_detailedLogger.LogStep(nameof(HandleMessageAsync), "ProcessingContext", $"transId={session.TurnState.TransId}");
|
||||
var replies = await turnFinalizationService.HandleContextAsync(session, envelope, cancellationToken);
|
||||
await telemetrySink.RecordTurnEventAsync(envelope, session, "context_received", new Dictionary<string, object?>
|
||||
{
|
||||
["transID"] = session.TurnState.TransId
|
||||
}, cancellationToken);
|
||||
_detailedLogger.LogExit(nameof(HandleMessageAsync), $"replies={replies.Count}");
|
||||
return replies;
|
||||
}
|
||||
case "LISTEN":
|
||||
{
|
||||
var replies = ContainsInlineTurnPayload(envelope.Text)
|
||||
var hasInlinePayload = ContainsInlineTurnPayload(envelope.Text);
|
||||
_detailedLogger.LogDecision(nameof(HandleMessageAsync), "ListenHandlerSelected", hasInlinePayload ? "inline_turn" : "listen_setup");
|
||||
var replies = hasInlinePayload
|
||||
? await turnFinalizationService.HandleTurnAsync(session, envelope, parsedType, cancellationToken)
|
||||
: WebSocketTurnFinalizationService.HandleListenSetup(session, envelope);
|
||||
await telemetrySink.RecordTurnEventAsync(envelope, session, "turn_processed", new Dictionary<string, object?>
|
||||
@@ -57,10 +87,12 @@ public sealed class JiboWebSocketService(
|
||||
["transcript"] = session.LastTranscript,
|
||||
["intent"] = session.LastIntent
|
||||
}, cancellationToken);
|
||||
_detailedLogger.LogExit(nameof(HandleMessageAsync), $"replies={replies.Count}");
|
||||
return replies;
|
||||
}
|
||||
case "CLIENT_NLU" or "CLIENT_ASR":
|
||||
{
|
||||
_detailedLogger.LogStep(nameof(HandleMessageAsync), "ProcessingTurn", $"type={parsedType}");
|
||||
var replies = await turnFinalizationService.HandleTurnAsync(session, envelope, parsedType, cancellationToken);
|
||||
await telemetrySink.RecordTurnEventAsync(envelope, session, "turn_processed", new Dictionary<string, object?>
|
||||
{
|
||||
@@ -69,9 +101,12 @@ public sealed class JiboWebSocketService(
|
||||
["transcript"] = session.LastTranscript,
|
||||
["intent"] = session.LastIntent
|
||||
}, cancellationToken);
|
||||
_detailedLogger.LogExit(nameof(HandleMessageAsync), $"replies={replies.Count}");
|
||||
return replies;
|
||||
}
|
||||
default:
|
||||
_detailedLogger.LogDecision(nameof(HandleMessageAsync), "UnknownMessageType", $"type={parsedType}");
|
||||
_detailedLogger.LogExit(nameof(HandleMessageAsync), "empty");
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
using System.Text.Json;
|
||||
using Jibo.Cloud.Application.Abstractions;
|
||||
using Jibo.Cloud.Application.Logging;
|
||||
using Jibo.Cloud.Domain.Models;
|
||||
using Jibo.Runtime.Abstractions;
|
||||
using System.Text.RegularExpressions;
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace Jibo.Cloud.Application.Services;
|
||||
|
||||
public sealed partial class WebSocketTurnFinalizationService(
|
||||
IConversationBroker conversationBroker,
|
||||
ISttStrategySelector sttStrategySelector,
|
||||
ITurnTelemetrySink sink
|
||||
)
|
||||
ITurnTelemetrySink sink,
|
||||
ILogger<WebSocketTurnFinalizationService> logger)
|
||||
{
|
||||
private readonly DetailedOperationLogger _detailedLogger = new(logger);
|
||||
private const int AutoFinalizeMinBufferedAudioBytes = 12000;
|
||||
private const int AutoFinalizeMinBufferedAudioChunks = 4;
|
||||
private static readonly TimeSpan AutoFinalizeMinTurnAge = TimeSpan.FromMilliseconds(1400);
|
||||
@@ -36,12 +39,18 @@ public sealed partial class WebSocketTurnFinalizationService(
|
||||
WebSocketMessageEnvelope envelope,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
_detailedLogger.LogEntry(nameof(HandleBinaryAudioAsync),
|
||||
("sessionId", session.SessionId),
|
||||
("audioBytes", envelope.Binary?.Length ?? 0));
|
||||
|
||||
var turnState = session.TurnState;
|
||||
if (ShouldIgnoreLateAudio(session) || !turnState.AwaitingTurnCompletion &&
|
||||
!session.FollowUpOpen &&
|
||||
!turnState.SawListen &&
|
||||
!string.IsNullOrWhiteSpace(turnState.TransId))
|
||||
{
|
||||
_detailedLogger.LogDecision(nameof(HandleBinaryAudioAsync), "IgnoringLateAudio", $"transId={turnState.TransId}");
|
||||
_detailedLogger.LogExit(nameof(HandleBinaryAudioAsync), "empty");
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -59,9 +68,14 @@ public sealed partial class WebSocketTurnFinalizationService(
|
||||
|
||||
if (ShouldAutoFinalize(session))
|
||||
{
|
||||
return await FinalizeTurnAsync(session, envelope, "AUTO_FINALIZE", allowFallbackOnMissingTranscript: true, cancellationToken);
|
||||
_detailedLogger.LogDecision(nameof(HandleBinaryAudioAsync), "AutoFinalizing", $"chunks={turnState.BufferedAudioChunkCount}, bytes={turnState.BufferedAudioBytes}");
|
||||
var replies = await FinalizeTurnAsync(session, envelope, "AUTO_FINALIZE", allowFallbackOnMissingTranscript: true, cancellationToken);
|
||||
_detailedLogger.LogExit(nameof(HandleBinaryAudioAsync), $"replies={replies.Count}");
|
||||
return replies;
|
||||
}
|
||||
|
||||
_detailedLogger.LogStep(nameof(HandleBinaryAudioAsync), "BufferingAudio", $"chunks={turnState.BufferedAudioChunkCount}, bytes={turnState.BufferedAudioBytes}");
|
||||
_detailedLogger.LogExit(nameof(HandleBinaryAudioAsync), "empty-awaiting-more");
|
||||
return [];
|
||||
}
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ namespace Jibo.Cloud.Infrastructure.DependencyInjection;
|
||||
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
public static IServiceCollection AddOpenJiboCloud(this IServiceCollection services, IConfiguration? configuration = null)
|
||||
public static IServiceCollection AddOpenJiboCloud(this IServiceCollection services, IConfiguration? configuration = null, int? logLevel = null)
|
||||
{
|
||||
var sttOptions = new BufferedAudioSttOptions();
|
||||
if (configuration is not null)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.Json;
|
||||
using Jibo.Cloud.Application.Abstractions;
|
||||
using Jibo.Cloud.Application.Logging;
|
||||
using Jibo.Cloud.Domain.Models;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
@@ -11,6 +12,7 @@ public sealed class FileWebSocketTelemetrySink(
|
||||
ILogger<FileWebSocketTelemetrySink> logger,
|
||||
IOptions<WebSocketTelemetryOptions> options) : IWebSocketTelemetrySink
|
||||
{
|
||||
private readonly DetailedOperationLogger _detailedLogger = new(logger);
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = true
|
||||
@@ -21,8 +23,14 @@ public sealed class FileWebSocketTelemetrySink(
|
||||
|
||||
public async Task RecordConnectionOpenedAsync(WebSocketMessageEnvelope envelope, CloudSession session, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_detailedLogger.LogEntry(nameof(RecordConnectionOpenedAsync),
|
||||
("sessionId", session.SessionId),
|
||||
("host", envelope.HostName),
|
||||
("kind", envelope.Kind));
|
||||
|
||||
if (!options.Value.Enabled)
|
||||
{
|
||||
_detailedLogger.LogStep(nameof(RecordConnectionOpenedAsync), "TelemetryDisabled");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -42,10 +50,20 @@ public sealed class FileWebSocketTelemetrySink(
|
||||
|
||||
public Task RecordInboundAsync(WebSocketMessageEnvelope envelope, CloudSession session, string? messageType, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return !options.Value.Enabled
|
||||
? Task.CompletedTask
|
||||
: WriteRecordAsync(BuildRecord("message_in", envelope, session, messageType, "in", null, null),
|
||||
cancellationToken);
|
||||
_detailedLogger.LogEntry(nameof(RecordInboundAsync),
|
||||
("sessionId", session.SessionId),
|
||||
("messageType", messageType),
|
||||
("textLength", envelope.Text?.Length ?? 0),
|
||||
("binaryLength", envelope.Binary?.Length ?? 0));
|
||||
|
||||
if (!options.Value.Enabled)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
_detailedLogger.LogPayload(nameof(RecordInboundAsync), "WebSocketMessage", envelope.Text?.Length ?? envelope.Binary?.Length ?? 0, envelope.Text?[..Math.Min(100, envelope.Text?.Length ?? 0)]);
|
||||
|
||||
return WriteRecordAsync(BuildRecord("message_in", envelope, session, messageType, "in", null, null), cancellationToken);
|
||||
}
|
||||
|
||||
public Task RecordTurnEventAsync(WebSocketMessageEnvelope envelope, CloudSession session, string eventType, IReadOnlyDictionary<string, object?> details, CancellationToken cancellationToken = default)
|
||||
@@ -58,11 +76,17 @@ public sealed class FileWebSocketTelemetrySink(
|
||||
|
||||
public async Task RecordOutboundAsync(WebSocketMessageEnvelope envelope, CloudSession session, IReadOnlyList<WebSocketReply> replies, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_detailedLogger.LogEntry(nameof(RecordOutboundAsync),
|
||||
("sessionId", session.SessionId),
|
||||
("replyCount", replies.Count));
|
||||
|
||||
if (!options.Value.Enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_detailedLogger.LogState(nameof(RecordOutboundAsync), "ReplyCount", replies.Count);
|
||||
|
||||
var replyTypes = replies
|
||||
.Select(reply => ReadReplyType(reply.Text))
|
||||
.Where(type => !string.IsNullOrWhiteSpace(type))
|
||||
|
||||
Reference in New Issue
Block a user