├── assets
└── image.png
├── .gitignore
├── src
└── Spector
│ ├── ui-src
│ ├── tsconfig.json
│ ├── src
│ │ ├── components
│ │ │ ├── TraceList
│ │ │ │ ├── TraceList.module.css
│ │ │ │ ├── EmptyState.tsx
│ │ │ │ ├── EmptyState.module.css
│ │ │ │ ├── TraceList.tsx
│ │ │ │ ├── TraceGroup.module.css
│ │ │ │ ├── TraceGroup.tsx
│ │ │ │ ├── ActivityItem.module.css
│ │ │ │ └── ActivityItem.tsx
│ │ │ ├── common
│ │ │ │ ├── Badge.module.css
│ │ │ │ ├── Badge.tsx
│ │ │ │ ├── Button.tsx
│ │ │ │ └── Button.module.css
│ │ │ ├── Header
│ │ │ │ ├── Header.tsx
│ │ │ │ └── Header.module.css
│ │ │ ├── Sidebar
│ │ │ │ ├── Sidebar.module.css
│ │ │ │ └── Sidebar.tsx
│ │ │ └── DetailsPanel
│ │ │ │ ├── DetailsPanel.module.css
│ │ │ │ └── DetailsPanel.tsx
│ │ ├── main.tsx
│ │ ├── styles
│ │ │ ├── variables.css
│ │ │ └── global.css
│ │ ├── App.css
│ │ ├── utils
│ │ │ ├── duration.ts
│ │ │ ├── hierarchy.ts
│ │ │ └── formatting.ts
│ │ ├── types
│ │ │ └── index.ts
│ │ ├── App.tsx
│ │ ├── index.css
│ │ ├── hooks
│ │ │ └── useSSE.ts
│ │ ├── assets
│ │ │ └── react.svg
│ │ └── context
│ │ │ └── SpectorContext.tsx
│ ├── .gitignore
│ ├── index.html
│ ├── eslint.config.js
│ ├── vite.config.ts
│ ├── tsconfig.node.json
│ ├── package.json
│ ├── tsconfig.app.json
│ ├── public
│ │ └── vite.svg
│ └── README.md
│ ├── wwwroot
│ └── index.html
│ ├── Storage
│ └── InMemoryTraceStore.cs
│ ├── Config
│ └── SpectorOptions.cs
│ ├── Models
│ └── TraceModels.cs
│ ├── Handlers
│ ├── SpectorHttpHandlerFilter.cs
│ └── SpectorHttpHandler.cs
│ ├── Spector.csproj
│ ├── Service
│ └── ActivityCollectorService.cs
│ ├── Middleware
│ └── HttpActivityMiddleware.cs
│ ├── README.md
│ └── SpectorExtensions.cs
├── tests
└── NetworkInspector.TestApi
│ ├── appsettings.json
│ ├── appsettings.Development.json
│ ├── NetworkInspector.TestApi.http
│ ├── NetworkInspector.TestApi.csproj
│ ├── Program.cs
│ ├── Properties
│ └── launchSettings.json
│ └── Controllers
│ └── TestController.cs
├── LICENSE
├── NetworkInspector.sln.DotSettings.user
├── NetworkInspector.sln
└── README.md
/assets/image.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/yashwanthkkn/spector/HEAD/assets/image.png
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | bin
2 | obj
3 | .idea
4 | nupkg
5 |
6 | # Node.js
7 | **/node_modules/
8 | **/package-lock.json
9 |
10 | # Build output
11 | **/wwwroot/
12 |
--------------------------------------------------------------------------------
/src/Spector/ui-src/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "files": [],
3 | "references": [
4 | { "path": "./tsconfig.app.json" },
5 | { "path": "./tsconfig.node.json" }
6 | ]
7 | }
8 |
--------------------------------------------------------------------------------
/tests/NetworkInspector.TestApi/appsettings.json:
--------------------------------------------------------------------------------
1 | {
2 | "Logging": {
3 | "LogLevel": {
4 | "Default": "Information",
5 | "Microsoft.AspNetCore": "Warning"
6 | }
7 | },
8 | "AllowedHosts": "*"
9 | }
10 |
--------------------------------------------------------------------------------
/src/Spector/ui-src/src/components/TraceList/TraceList.module.css:
--------------------------------------------------------------------------------
1 | .contentArea {
2 | flex: 1;
3 | overflow-y: auto;
4 | padding: 1.5rem;
5 | }
6 |
7 | .traceList {
8 | display: flex;
9 | flex-direction: column;
10 | gap: 1rem;
11 | }
--------------------------------------------------------------------------------
/src/Spector/ui-src/src/main.tsx:
--------------------------------------------------------------------------------
1 | import { StrictMode } from 'react'
2 | import { createRoot } from 'react-dom/client'
3 | import App from './App.tsx'
4 |
5 | createRoot(document.getElementById('root')!).render(
6 |
7 |
8 | ,
9 | )
10 |
--------------------------------------------------------------------------------
/tests/NetworkInspector.TestApi/appsettings.Development.json:
--------------------------------------------------------------------------------
1 | {
2 | "Logging": {
3 | "LogLevel": {
4 | "Default": "Information",
5 | "Microsoft.AspNetCore": "Warning",
6 | "NetworkInspector": "Debug",
7 | "System.Net.Http": "Information"
8 | }
9 | }
10 | }
11 |
--------------------------------------------------------------------------------
/src/Spector/ui-src/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 |
--------------------------------------------------------------------------------
/tests/NetworkInspector.TestApi/NetworkInspector.TestApi.http:
--------------------------------------------------------------------------------
1 | @NetworkInspector.TestApi_HostAddress =
2 |
3 | GET http://localhost:5031/test/http-call/
4 | Accept: application/json
5 |
6 | ###
7 |
8 | GET http://localhost:5031/test/http-call1/
9 | Accept: application/json
10 |
11 | ###
12 |
13 | POST http://localhost:5031/test/echo/
14 | Accept: application/json
15 |
16 | {
17 | "name":"yash"
18 | }
--------------------------------------------------------------------------------
/src/Spector/ui-src/src/components/common/Badge.module.css:
--------------------------------------------------------------------------------
1 | .badge {
2 | display: inline-block;
3 | padding: 0.1875rem 0.625rem;
4 | border-radius: 10px;
5 | font-size: 0.6875rem;
6 | font-weight: 600;
7 | }
8 |
9 | .httpin {
10 | background: rgba(74, 158, 255, 0.15);
11 | color: var(--http-in);
12 | }
13 |
14 | .httpout {
15 | background: rgba(52, 211, 153, 0.15);
16 | color: var(--http-out);
17 | }
--------------------------------------------------------------------------------
/src/Spector/ui-src/src/components/TraceList/EmptyState.tsx:
--------------------------------------------------------------------------------
1 |
2 | import styles from './EmptyState.module.css';
3 |
4 | export function EmptyState() {
5 | return (
6 |
7 |
📡
8 |
Waiting for network activity...
9 |
Make API requests to see them appear here
10 |
11 | );
12 | }
13 |
--------------------------------------------------------------------------------
/src/Spector/ui-src/src/components/TraceList/EmptyState.module.css:
--------------------------------------------------------------------------------
1 | .emptyState {
2 | text-align: center;
3 | padding: 4rem 2rem;
4 | color: var(--text-secondary);
5 | }
6 |
7 | .emptyIcon {
8 | font-size: 4rem;
9 | margin-bottom: 1rem;
10 | opacity: 0.5;
11 | }
12 |
13 | .emptyState p {
14 | font-size: 1.125rem;
15 | margin-bottom: 0.5rem;
16 | }
17 |
18 | .emptyState small {
19 | color: var(--text-muted);
20 | }
--------------------------------------------------------------------------------
/src/Spector/ui-src/src/components/common/Badge.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styles from './Badge.module.css';
3 |
4 | interface BadgeProps {
5 | type: 'httpin' | 'httpout';
6 | children: React.ReactNode;
7 | }
8 |
9 | export function Badge({ type, children }: BadgeProps) {
10 | return (
11 |
12 | {children}
13 |
14 | );
15 | }
16 |
--------------------------------------------------------------------------------
/src/Spector/ui-src/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Spector - Network Inspector
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/src/Spector/wwwroot/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 | Spector - Network Inspector
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/src/Spector/Storage/InMemoryTraceStore.cs:
--------------------------------------------------------------------------------
1 | using System.Collections.Concurrent;
2 | using Spector.Models;
3 |
4 | namespace Spector.Storage;
5 |
6 | public class InMemoryTraceStore
7 | {
8 | private readonly ConcurrentQueue _q = new();
9 | private readonly int _max;
10 |
11 | public InMemoryTraceStore(int max = 5000) => _max = max;
12 |
13 | public void Add(TraceDto dto)
14 | {
15 | _q.Enqueue(dto);
16 | while (_q.Count > _max && _q.TryDequeue(out _)) { }
17 | }
18 |
19 | public List GetAll() => _q.ToList();
20 | }
--------------------------------------------------------------------------------
/src/Spector/Config/SpectorOptions.cs:
--------------------------------------------------------------------------------
1 | namespace Spector.Config;
2 |
3 | public sealed class SpectorOptions
4 | {
5 | public string ActivitySourceName { get; set; } = "Spector.ActivitySource";
6 | public int InMemoryMaxTraces { get; set; } = 5000;
7 | public int CollectorChannelCapacity { get; set; } = 5000;
8 | public string UiPath { get; set; } = "/spector";
9 | public string SseEndpoint { get; set; } = "/spector/events";
10 | public bool RecordRequestBodies { get; set; } = true; // dev-only
11 | public bool RecordResponseBodies { get; set; } = true;
12 | }
--------------------------------------------------------------------------------
/tests/NetworkInspector.TestApi/NetworkInspector.TestApi.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | net8.0
5 | enable
6 | enable
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
--------------------------------------------------------------------------------
/src/Spector/ui-src/src/components/common/Button.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import styles from './Button.module.css';
3 |
4 | interface ButtonProps extends React.ButtonHTMLAttributes {
5 | variant?: 'primary' | 'secondary';
6 | children: React.ReactNode;
7 | }
8 |
9 | export function Button({ variant = 'secondary', children, className, ...props }: ButtonProps) {
10 | return (
11 |
15 | {children}
16 |
17 | );
18 | }
19 |
--------------------------------------------------------------------------------
/src/Spector/ui-src/src/styles/variables.css:
--------------------------------------------------------------------------------
1 | :root {
2 | --bg-primary: #0e1117;
3 | --bg-secondary: #1a1d29;
4 | --bg-tertiary: #252837;
5 | --bg-hover: #2d3142;
6 | --border-color: #363b4e;
7 | --text-primary: #e8eaed;
8 | --text-secondary: #9aa0a6;
9 | --text-muted: #5f6368;
10 | --accent-blue: #4a9eff;
11 | --accent-green: #34d399;
12 | --accent-orange: #fb923c;
13 | --accent-red: #f87171;
14 | --accent-purple: #a78bfa;
15 | --http-in: #4a9eff;
16 | --http-out: #34d399;
17 | --shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3), 0 2px 4px -1px rgba(0, 0, 0, 0.2);
18 | --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.4), 0 4px 6px -2px rgba(0, 0, 0, 0.3);
19 | }
20 |
--------------------------------------------------------------------------------
/tests/NetworkInspector.TestApi/Program.cs:
--------------------------------------------------------------------------------
1 | using NetworkInspector;
2 | using Spector;
3 |
4 | var builder = WebApplication.CreateBuilder(args);
5 |
6 | // Add services to the container.
7 | builder.Services.AddLogging();
8 | builder.Services.AddControllers();
9 | builder.Services.AddEndpointsApiExplorer();
10 | builder.Services.AddHttpClient();
11 |
12 | // Add Network Inspector - this is the ONLY line needed!
13 | builder.Services.AddSpector();
14 |
15 | var app = builder.Build();
16 |
17 | // Configure the HTTP request pipeline.
18 | app.UseSpector();
19 | app.UseAuthorization();
20 |
21 | // Use Network Inspector Middleware
22 | //app.UseNetworkInspector();
23 |
24 |
25 | app.MapControllers();
26 |
27 | app.Run();
28 |
--------------------------------------------------------------------------------
/src/Spector/ui-src/eslint.config.js:
--------------------------------------------------------------------------------
1 | import js from '@eslint/js'
2 | import globals from 'globals'
3 | import reactHooks from 'eslint-plugin-react-hooks'
4 | import reactRefresh from 'eslint-plugin-react-refresh'
5 | import tseslint from 'typescript-eslint'
6 | import { defineConfig, globalIgnores } from 'eslint/config'
7 |
8 | export default defineConfig([
9 | globalIgnores(['dist']),
10 | {
11 | files: ['**/*.{ts,tsx}'],
12 | extends: [
13 | js.configs.recommended,
14 | tseslint.configs.recommended,
15 | reactHooks.configs.flat.recommended,
16 | reactRefresh.configs.vite,
17 | ],
18 | languageOptions: {
19 | ecmaVersion: 2020,
20 | globals: globals.browser,
21 | },
22 | },
23 | ])
24 |
--------------------------------------------------------------------------------
/src/Spector/ui-src/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite'
2 | import react from '@vitejs/plugin-react'
3 |
4 | // https://vite.dev/config/
5 | export default defineConfig({
6 | plugins: [react()],
7 | base: './', // Use relative paths for assets
8 | build: {
9 | outDir: '../wwwroot',
10 | emptyOutDir: true,
11 | rollupOptions: {
12 | output: {
13 | entryFileNames: 'assets/[name]-src.js',
14 | chunkFileNames: 'assets/[name]-src.js',
15 | assetFileNames: 'assets/[name]-src.[ext]'
16 | }
17 | }
18 | },
19 | server: {
20 | port: 5173,
21 | proxy: {
22 | '/spector': {
23 | target: 'http://localhost:5000',
24 | changeOrigin: true
25 | }
26 | }
27 | }
28 | })
29 |
--------------------------------------------------------------------------------
/src/Spector/ui-src/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
4 | "target": "ES2023",
5 | "lib": ["ES2023"],
6 | "module": "ESNext",
7 | "types": ["node"],
8 | "skipLibCheck": true,
9 |
10 | /* Bundler mode */
11 | "moduleResolution": "bundler",
12 | "allowImportingTsExtensions": true,
13 | "verbatimModuleSyntax": true,
14 | "moduleDetection": "force",
15 | "noEmit": true,
16 |
17 | /* Linting */
18 | "strict": true,
19 | "noUnusedLocals": true,
20 | "noUnusedParameters": true,
21 | "erasableSyntaxOnly": true,
22 | "noFallthroughCasesInSwitch": true,
23 | "noUncheckedSideEffectImports": true
24 | },
25 | "include": ["vite.config.ts"]
26 | }
27 |
--------------------------------------------------------------------------------
/src/Spector/ui-src/src/App.css:
--------------------------------------------------------------------------------
1 | #root {
2 | max-width: 1280px;
3 | margin: 0 auto;
4 | padding: 2rem;
5 | text-align: center;
6 | }
7 |
8 | .logo {
9 | height: 6em;
10 | padding: 1.5em;
11 | will-change: filter;
12 | transition: filter 300ms;
13 | }
14 | .logo:hover {
15 | filter: drop-shadow(0 0 2em #646cffaa);
16 | }
17 | .logo.react:hover {
18 | filter: drop-shadow(0 0 2em #61dafbaa);
19 | }
20 |
21 | @keyframes logo-spin {
22 | from {
23 | transform: rotate(0deg);
24 | }
25 | to {
26 | transform: rotate(360deg);
27 | }
28 | }
29 |
30 | @media (prefers-reduced-motion: no-preference) {
31 | a:nth-of-type(2) .logo {
32 | animation: logo-spin infinite 20s linear;
33 | }
34 | }
35 |
36 | .card {
37 | padding: 2em;
38 | }
39 |
40 | .read-the-docs {
41 | color: #888;
42 | }
43 |
--------------------------------------------------------------------------------
/src/Spector/ui-src/src/components/common/Button.module.css:
--------------------------------------------------------------------------------
1 | .btn {
2 | padding: 0.5rem 1.25rem;
3 | border: none;
4 | border-radius: 6px;
5 | font-size: 0.875rem;
6 | font-weight: 500;
7 | cursor: pointer;
8 | transition: all 0.2s;
9 | outline: none;
10 | }
11 |
12 | .secondary {
13 | background: var(--bg-tertiary);
14 | color: var(--text-primary);
15 | border: 1px solid var(--border-color);
16 | }
17 |
18 | .secondary:hover {
19 | background: var(--bg-hover);
20 | transform: translateY(-1px);
21 | }
22 |
23 | .secondary:active {
24 | transform: translateY(0);
25 | }
26 |
27 | .primary {
28 | background: var(--accent-blue);
29 | color: white;
30 | }
31 |
32 | .primary:hover {
33 | background: var(--accent-purple);
34 | transform: translateY(-1px);
35 | }
--------------------------------------------------------------------------------
/src/Spector/ui-src/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "ui-src",
3 | "private": true,
4 | "version": "0.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite",
8 | "build": "tsc -b && vite build",
9 | "lint": "eslint .",
10 | "preview": "vite preview"
11 | },
12 | "dependencies": {
13 | "react": "^19.2.0",
14 | "react-dom": "^19.2.0"
15 | },
16 | "devDependencies": {
17 | "@eslint/js": "^9.39.1",
18 | "@types/node": "^24.10.1",
19 | "@types/react": "^19.2.5",
20 | "@types/react-dom": "^19.2.3",
21 | "@vitejs/plugin-react": "^5.1.1",
22 | "eslint": "^9.39.1",
23 | "eslint-plugin-react-hooks": "^7.0.1",
24 | "eslint-plugin-react-refresh": "^0.4.24",
25 | "globals": "^16.5.0",
26 | "typescript": "~5.9.3",
27 | "typescript-eslint": "^8.46.4",
28 | "vite": "^7.2.4"
29 | }
30 | }
31 |
--------------------------------------------------------------------------------
/src/Spector/Models/TraceModels.cs:
--------------------------------------------------------------------------------
1 | namespace Spector.Models;
2 |
3 | public record TraceEventDto
4 | {
5 | public string Name { get; init; } = string.Empty;
6 | public DateTimeOffset Timestamp { get; init; }
7 | public Dictionary Tags { get; init; } = new();
8 | }
9 |
10 | public record TraceDto
11 | {
12 | public string Name { get; init; } = string.Empty;
13 | public string TraceId { get; init; } = string.Empty;
14 | public string SpanId { get; init; } = string.Empty;
15 | public string ParentSpanId { get; init; } = string.Empty;
16 | public DateTime StartTimeUtc { get; init; }
17 | public TimeSpan Duration { get; init; }
18 | public string Kind { get; init; } = string.Empty;
19 | public Dictionary Tags { get; init; } = new();
20 | public List Events { get; init; } = new();
21 | }
--------------------------------------------------------------------------------
/src/Spector/ui-src/tsconfig.app.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
4 | "target": "ES2022",
5 | "useDefineForClassFields": true,
6 | "lib": ["ES2022", "DOM", "DOM.Iterable"],
7 | "module": "ESNext",
8 | "types": ["vite/client"],
9 | "skipLibCheck": true,
10 |
11 | /* Bundler mode */
12 | "moduleResolution": "bundler",
13 | "allowImportingTsExtensions": true,
14 | "verbatimModuleSyntax": true,
15 | "moduleDetection": "force",
16 | "noEmit": true,
17 | "jsx": "react-jsx",
18 |
19 | /* Linting */
20 | "strict": true,
21 | "noUnusedLocals": true,
22 | "noUnusedParameters": true,
23 | "erasableSyntaxOnly": true,
24 | "noFallthroughCasesInSwitch": true,
25 | "noUncheckedSideEffectImports": true
26 | },
27 | "include": ["src"]
28 | }
29 |
--------------------------------------------------------------------------------
/src/Spector/ui-src/src/utils/duration.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Parse duration string from format "HH:MM:SS.mmmmmmm" to milliseconds
3 | */
4 | export function parseDuration(duration: string): number {
5 | const parts = duration.split(':');
6 | const hours = parseInt(parts[0]);
7 | const minutes = parseInt(parts[1]);
8 | const secondsParts = parts[2].split('.');
9 | const seconds = parseInt(secondsParts[0]);
10 | const milliseconds = secondsParts[1] ? parseInt(secondsParts[1].substring(0, 3)) : 0;
11 |
12 | return (hours * 3600 + minutes * 60 + seconds) * 1000 + milliseconds;
13 | }
14 |
15 | /**
16 | * Format milliseconds to human-readable duration
17 | */
18 | export function formatDuration(ms: number): string {
19 | if (ms < 1) return `${(ms * 1000).toFixed(0)}μs`;
20 | if (ms < 1000) return `${ms.toFixed(0)}ms`;
21 | return `${(ms / 1000).toFixed(2)}s`;
22 | }
23 |
--------------------------------------------------------------------------------
/src/Spector/ui-src/src/types/index.ts:
--------------------------------------------------------------------------------
1 | // Activity data structure from SSE
2 | export interface Activity {
3 | TraceId: string;
4 | SpanId: string;
5 | ParentSpanId: string | null;
6 | Name: string;
7 | StartTimeUtc: string;
8 | Duration: string;
9 | Tags: Record;
10 | }
11 |
12 | // Trace group containing multiple activities
13 | export interface Trace {
14 | traceId: string;
15 | activities: Activity[];
16 | startTime: Date;
17 | endTime: Date;
18 | }
19 |
20 | // Filter state
21 | export interface Filters {
22 | httpIn: boolean;
23 | httpOut: boolean;
24 | }
25 |
26 | // Connection status
27 | export type ConnectionStatus = 'connected' | 'disconnected' | 'connecting';
28 |
29 | // Activity type
30 | export type ActivityType = 'httpin' | 'httpout';
31 |
32 | // HTTP methods
33 | export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'OPTIONS' | 'HEAD';
34 |
35 | // Status code ranges
36 | export type StatusType = 'success' | 'redirect' | 'client-error' | 'server-error' | 'error';
37 |
--------------------------------------------------------------------------------
/src/Spector/ui-src/src/components/TraceList/TraceList.tsx:
--------------------------------------------------------------------------------
1 | import { useMemo } from 'react';
2 | import { useSpector } from '../../context/SpectorContext';
3 | import { TraceGroup } from './TraceGroup';
4 | import { EmptyState } from './EmptyState';
5 | import styles from './TraceList.module.css';
6 |
7 | export function TraceList() {
8 | const { traces } = useSpector();
9 |
10 | const sortedTraces = useMemo(() => {
11 | return Array.from(traces.values())
12 | .sort((a, b) => b.startTime.getTime() - a.startTime.getTime());
13 | }, [traces]);
14 |
15 | if (sortedTraces.length === 0) {
16 | return (
17 |
18 |
19 |
20 | );
21 | }
22 |
23 | return (
24 |
25 |
26 | {sortedTraces.map(trace => (
27 |
28 | ))}
29 |
30 |
31 | );
32 | }
33 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2025 Yashwanth K
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/src/Spector/ui-src/src/utils/hierarchy.ts:
--------------------------------------------------------------------------------
1 | import type { Activity } from '../types';
2 |
3 | /**
4 | * Build parent-child hierarchy from flat activity list
5 | */
6 | export function buildActivityHierarchy(activities: Activity[]): {
7 | rootActivities: Activity[];
8 | childrenMap: Map;
9 | } {
10 | const rootActivities: Activity[] = [];
11 | const childrenMap = new Map();
12 | const activityMap = new Map();
13 |
14 | // Create activity lookup map
15 | activities.forEach(activity => {
16 | activityMap.set(activity.SpanId, activity);
17 | });
18 |
19 | // Build hierarchy
20 | activities.forEach(activity => {
21 | if (!activity.ParentSpanId || !activityMap.has(activity.ParentSpanId)) {
22 | rootActivities.push(activity);
23 | } else {
24 | if (!childrenMap.has(activity.ParentSpanId)) {
25 | childrenMap.set(activity.ParentSpanId, []);
26 | }
27 | childrenMap.get(activity.ParentSpanId)!.push(activity);
28 | }
29 | });
30 |
31 | return { rootActivities, childrenMap };
32 | }
33 |
--------------------------------------------------------------------------------
/src/Spector/ui-src/src/App.tsx:
--------------------------------------------------------------------------------
1 | import { SpectorProvider, useSpector } from './context/SpectorContext';
2 | import { useSSE } from './hooks/useSSE';
3 | import { Header } from './components/Header/Header';
4 | import { Sidebar } from './components/Sidebar/Sidebar';
5 | import { TraceList } from './components/TraceList/TraceList';
6 | import { DetailsPanel } from './components/DetailsPanel/DetailsPanel';
7 | import './styles/variables.css';
8 | import './styles/global.css';
9 |
10 | function AppContent() {
11 | const { addActivity, setConnectionStatus, isPaused } = useSpector();
12 |
13 | useSSE({
14 | url: '/spector/events',
15 | onMessage: addActivity,
16 | onStatusChange: setConnectionStatus,
17 | isPaused
18 | });
19 |
20 | return (
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 | );
30 | }
31 |
32 | function App() {
33 | return (
34 |
35 |
36 |
37 | );
38 | }
39 |
40 | export default App;
41 |
--------------------------------------------------------------------------------
/NetworkInspector.sln.DotSettings.user:
--------------------------------------------------------------------------------
1 |
2 | ForceIncluded
3 | ForceIncluded
--------------------------------------------------------------------------------
/src/Spector/ui-src/src/styles/global.css:
--------------------------------------------------------------------------------
1 | * {
2 | margin: 0;
3 | padding: 0;
4 | box-sizing: border-box;
5 | }
6 |
7 | body {
8 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
9 | background: var(--bg-primary);
10 | color: var(--text-primary);
11 | line-height: 1.6;
12 | overflow: hidden;
13 | }
14 |
15 | .container {
16 | height: 100vh;
17 | display: flex;
18 | flex-direction: column;
19 | }
20 |
21 | /* Scrollbar Styling */
22 | ::-webkit-scrollbar {
23 | width: 8px;
24 | height: 8px;
25 | }
26 |
27 | ::-webkit-scrollbar-track {
28 | background: var(--bg-primary);
29 | }
30 |
31 | ::-webkit-scrollbar-thumb {
32 | background: var(--border-color);
33 | border-radius: 4px;
34 | }
35 |
36 | ::-webkit-scrollbar-thumb:hover {
37 | background: var(--text-muted);
38 | }
39 |
40 | /* Animations */
41 | @keyframes pulse {
42 |
43 | 0%,
44 | 100% {
45 | opacity: 1;
46 | }
47 |
48 | 50% {
49 | opacity: 0.5;
50 | }
51 | }
52 |
53 | @keyframes slideIn {
54 | from {
55 | opacity: 0;
56 | transform: translateY(-10px);
57 | }
58 |
59 | to {
60 | opacity: 1;
61 | transform: translateY(0);
62 | }
63 | }
--------------------------------------------------------------------------------
/tests/NetworkInspector.TestApi/Properties/launchSettings.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "http://json.schemastore.org/launchsettings.json",
3 | "iisSettings": {
4 | "windowsAuthentication": false,
5 | "anonymousAuthentication": true,
6 | "iisExpress": {
7 | "applicationUrl": "http://localhost:50588",
8 | "sslPort": 44356
9 | }
10 | },
11 | "profiles": {
12 | "http": {
13 | "commandName": "Project",
14 | "dotnetRunMessages": true,
15 | "launchBrowser": false,
16 | "launchUrl": "swagger",
17 | "applicationUrl": "http://localhost:5031",
18 | "environmentVariables": {
19 | "ASPNETCORE_ENVIRONMENT": "Development"
20 | }
21 | },
22 | "https": {
23 | "commandName": "Project",
24 | "dotnetRunMessages": true,
25 | "launchBrowser": true,
26 | "launchUrl": "swagger",
27 | "applicationUrl": "https://localhost:7226;http://localhost:5031",
28 | "environmentVariables": {
29 | "ASPNETCORE_ENVIRONMENT": "Development"
30 | }
31 | },
32 | "IIS Express": {
33 | "commandName": "IISExpress",
34 | "launchBrowser": true,
35 | "launchUrl": "swagger",
36 | "environmentVariables": {
37 | "ASPNETCORE_ENVIRONMENT": "Development"
38 | }
39 | }
40 | }
41 | }
42 |
--------------------------------------------------------------------------------
/src/Spector/Handlers/SpectorHttpHandlerFilter.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.Extensions.DependencyInjection;
2 | using Microsoft.Extensions.Http;
3 |
4 | namespace Spector.Handlers;
5 |
6 | public class SpectorHttpHandlerFilter : IHttpMessageHandlerBuilderFilter
7 | {
8 | private readonly IServiceProvider _serviceProvider;
9 |
10 | public SpectorHttpHandlerFilter(IServiceProvider serviceProvider)
11 | {
12 | _serviceProvider = serviceProvider;
13 | }
14 |
15 | public Action Configure(Action next)
16 | {
17 | return builder =>
18 | {
19 | // run the rest of the builders first
20 | next(builder);
21 |
22 | // add our handler instance from DI (transient)
23 | var handler = _serviceProvider.GetRequiredService();
24 | if (handler != null)
25 | {
26 | // wrapping: if there is already a primary handler assign its InnerHandler accordingly
27 | // We put tracingHandler at the end of the pipeline so it is the outermost delegating handler.
28 | handler.InnerHandler = builder.PrimaryHandler ?? new HttpClientHandler();
29 | builder.PrimaryHandler = handler;
30 | }
31 | };
32 | }
33 | }
--------------------------------------------------------------------------------
/src/Spector/ui-src/src/components/Header/Header.tsx:
--------------------------------------------------------------------------------
1 |
2 | import { useSpector } from '../../context/SpectorContext';
3 | import { Button } from '../common/Button';
4 | import styles from './Header.module.css';
5 |
6 | export function Header() {
7 | const { clearAll, togglePause, isPaused, connectionStatus } = useSpector();
8 |
9 | return (
10 |
28 | );
29 | }
30 |
--------------------------------------------------------------------------------
/src/Spector/ui-src/src/index.css:
--------------------------------------------------------------------------------
1 | :root {
2 | font-family: system-ui, Avenir, Helvetica, Arial, sans-serif;
3 | line-height: 1.5;
4 | font-weight: 400;
5 |
6 | color-scheme: light dark;
7 | color: rgba(255, 255, 255, 0.87);
8 | background-color: #242424;
9 |
10 | font-synthesis: none;
11 | text-rendering: optimizeLegibility;
12 | -webkit-font-smoothing: antialiased;
13 | -moz-osx-font-smoothing: grayscale;
14 | }
15 |
16 | a {
17 | font-weight: 500;
18 | color: #646cff;
19 | text-decoration: inherit;
20 | }
21 | a:hover {
22 | color: #535bf2;
23 | }
24 |
25 | body {
26 | margin: 0;
27 | display: flex;
28 | place-items: center;
29 | min-width: 320px;
30 | min-height: 100vh;
31 | }
32 |
33 | h1 {
34 | font-size: 3.2em;
35 | line-height: 1.1;
36 | }
37 |
38 | button {
39 | border-radius: 8px;
40 | border: 1px solid transparent;
41 | padding: 0.6em 1.2em;
42 | font-size: 1em;
43 | font-weight: 500;
44 | font-family: inherit;
45 | background-color: #1a1a1a;
46 | cursor: pointer;
47 | transition: border-color 0.25s;
48 | }
49 | button:hover {
50 | border-color: #646cff;
51 | }
52 | button:focus,
53 | button:focus-visible {
54 | outline: 4px auto -webkit-focus-ring-color;
55 | }
56 |
57 | @media (prefers-color-scheme: light) {
58 | :root {
59 | color: #213547;
60 | background-color: #ffffff;
61 | }
62 | a:hover {
63 | color: #747bff;
64 | }
65 | button {
66 | background-color: #f9f9f9;
67 | }
68 | }
69 |
--------------------------------------------------------------------------------
/src/Spector/ui-src/public/vite.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/Spector/ui-src/src/components/Header/Header.module.css:
--------------------------------------------------------------------------------
1 | .header {
2 | background: var(--bg-secondary);
3 | border-bottom: 1px solid var(--border-color);
4 | padding: 0.75rem 1.25rem;
5 | display: flex;
6 | justify-content: space-between;
7 | align-items: center;
8 | box-shadow: var(--shadow);
9 | z-index: 10;
10 | }
11 |
12 | .headerContent {
13 | display: flex;
14 | align-items: center;
15 | gap: 1.5rem;
16 | }
17 |
18 | .title {
19 | font-size: 1.25rem;
20 | font-weight: 600;
21 | background: linear-gradient(135deg, var(--accent-blue), var(--accent-purple));
22 | -webkit-background-clip: text;
23 | -webkit-text-fill-color: transparent;
24 | background-clip: text;
25 | }
26 |
27 | .connectionStatus {
28 | display: flex;
29 | align-items: center;
30 | gap: 0.5rem;
31 | padding: 0.375rem 0.875rem;
32 | background: var(--bg-tertiary);
33 | border-radius: 16px;
34 | font-size: 0.8125rem;
35 | }
36 |
37 | .statusIndicator {
38 | width: 7px;
39 | height: 7px;
40 | border-radius: 50%;
41 | background: var(--text-muted);
42 | }
43 |
44 | .statusIndicator.connected {
45 | background: var(--accent-green);
46 | animation: pulse 2s infinite;
47 | }
48 |
49 | .statusIndicator.disconnected {
50 | background: var(--accent-red);
51 | }
52 |
53 | .statusIndicator.connecting {
54 | background: var(--accent-orange);
55 | animation: pulse 2s infinite;
56 | }
57 |
58 | .controls {
59 | display: flex;
60 | gap: 0.75rem;
61 | }
62 |
63 | @media (max-width: 768px) {
64 | .header {
65 | flex-direction: column;
66 | gap: 1rem;
67 | align-items: flex-start;
68 | }
69 | }
--------------------------------------------------------------------------------
/src/Spector/ui-src/src/components/TraceList/TraceGroup.module.css:
--------------------------------------------------------------------------------
1 | .traceGroup {
2 | background: var(--bg-secondary);
3 | border: 1px solid var(--border-color);
4 | border-radius: 6px;
5 | overflow: hidden;
6 | transition: all 0.3s;
7 | animation: slideIn 0.3s ease-out;
8 | }
9 |
10 | .traceHeader {
11 | padding: 0.625rem 1rem;
12 | background: var(--bg-tertiary);
13 | border-bottom: 1px solid var(--border-color);
14 | cursor: pointer;
15 | display: flex;
16 | justify-content: space-between;
17 | align-items: center;
18 | transition: background 0.2s;
19 | }
20 |
21 | .traceHeader:hover {
22 | background: var(--bg-hover);
23 | }
24 |
25 | .traceHeader.collapsed {
26 | border-bottom: none;
27 | }
28 |
29 | .traceInfo {
30 | flex: 1;
31 | }
32 |
33 | .traceTitle {
34 | font-size: 0.75rem;
35 | color: var(--text-secondary);
36 | margin-bottom: 0.125rem;
37 | }
38 |
39 | .traceId {
40 | font-family: 'Courier New', monospace;
41 | font-size: 0.6875rem;
42 | color: var(--text-muted);
43 | }
44 |
45 | .traceMeta {
46 | display: flex;
47 | align-items: center;
48 | gap: 0.75rem;
49 | }
50 |
51 | .traceDuration {
52 | font-size: 0.75rem;
53 | font-weight: 600;
54 | color: var(--accent-orange);
55 | }
56 |
57 | .traceCount {
58 | font-size: 0.6875rem;
59 | padding: 0.125rem 0.5rem;
60 | background: var(--bg-primary);
61 | border-radius: 10px;
62 | color: var(--text-secondary);
63 | }
64 |
65 | .collapseIcon {
66 | font-size: 1rem;
67 | color: var(--text-secondary);
68 | transition: transform 0.3s;
69 | }
70 |
71 | .collapseIcon.collapsed {
72 | transform: rotate(-90deg);
73 | }
74 |
75 | .traceActivities {
76 | padding: 0.375rem;
77 | }
--------------------------------------------------------------------------------
/src/Spector/ui-src/src/components/Sidebar/Sidebar.module.css:
--------------------------------------------------------------------------------
1 | .sidebar {
2 | width: 250px;
3 | background: var(--bg-secondary);
4 | border-right: 1px solid var(--border-color);
5 | padding: 1.5rem;
6 | overflow-y: auto;
7 | }
8 |
9 | .sidebar h3 {
10 | font-size: 0.875rem;
11 | font-weight: 600;
12 | text-transform: uppercase;
13 | letter-spacing: 0.5px;
14 | color: var(--text-secondary);
15 | margin-bottom: 1rem;
16 | }
17 |
18 | .filterSection {
19 | margin-bottom: 2rem;
20 | }
21 |
22 | .filterGroup {
23 | display: flex;
24 | flex-direction: column;
25 | gap: 0.75rem;
26 | }
27 |
28 | .filterGroup label {
29 | display: flex;
30 | align-items: center;
31 | cursor: pointer;
32 | padding: 0.5rem;
33 | border-radius: 6px;
34 | transition: background 0.2s;
35 | }
36 |
37 | .filterGroup label:hover {
38 | background: var(--bg-tertiary);
39 | }
40 |
41 | .filterGroup input[type="checkbox"] {
42 | margin-right: 0.75rem;
43 | width: 16px;
44 | height: 16px;
45 | cursor: pointer;
46 | }
47 |
48 | .filterLabel {
49 | font-size: 0.875rem;
50 | padding: 0.25rem 0.75rem;
51 | border-radius: 4px;
52 | font-weight: 500;
53 | }
54 |
55 | .filterLabel.httpIn {
56 | background: rgba(74, 158, 255, 0.15);
57 | color: var(--http-in);
58 | }
59 |
60 | .filterLabel.httpOut {
61 | background: rgba(52, 211, 153, 0.15);
62 | color: var(--http-out);
63 | }
64 |
65 | .statsSection {
66 | padding-top: 1.5rem;
67 | border-top: 1px solid var(--border-color);
68 | }
69 |
70 | .statItem {
71 | display: flex;
72 | justify-content: space-between;
73 | padding: 0.5rem 0;
74 | font-size: 0.875rem;
75 | }
76 |
77 | .statLabel {
78 | color: var(--text-secondary);
79 | }
80 |
81 | .statValue {
82 | font-weight: 600;
83 | color: var(--accent-blue);
84 | }
85 |
86 | @media (max-width: 768px) {
87 | .sidebar {
88 | display: none;
89 | }
90 | }
--------------------------------------------------------------------------------
/src/Spector/ui-src/src/components/Sidebar/Sidebar.tsx:
--------------------------------------------------------------------------------
1 |
2 | import { useSpector } from '../../context/SpectorContext';
3 | import styles from './Sidebar.module.css';
4 |
5 | export function Sidebar() {
6 | const { filters, setFilter, traces, activities } = useSpector();
7 |
8 | return (
9 |
10 |
31 |
32 |
33 |
Statistics
34 |
35 | Total Requests:
36 | {activities.size}
37 |
38 |
39 | Active Traces:
40 | {traces.size}
41 |
42 |
43 |
44 | );
45 | }
46 |
--------------------------------------------------------------------------------
/NetworkInspector.sln:
--------------------------------------------------------------------------------
1 |
2 | Microsoft Visual Studio Solution File, Format Version 12.00
3 | # Visual Studio Version 17
4 | VisualStudioVersion = 17.0.31903.59
5 | MinimumVisualStudioVersion = 10.0.40219.1
6 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{4CB7EB44-1422-42E0-BA49-0402DE7ED1C9}"
7 | EndProject
8 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{C76BB15B-F3FF-430B-AD6C-7BD27CEA938A}"
9 | EndProject
10 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NetworkInspector.TestApi", "tests\NetworkInspector.TestApi\NetworkInspector.TestApi.csproj", "{AFD1C0F1-0B77-4B0E-85E0-4E1754D7022B}"
11 | EndProject
12 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Spector", "src\Spector\Spector.csproj", "{F23511D3-1558-43FE-9A05-98172BB62205}"
13 | EndProject
14 | Global
15 | GlobalSection(SolutionConfigurationPlatforms) = preSolution
16 | Debug|Any CPU = Debug|Any CPU
17 | Release|Any CPU = Release|Any CPU
18 | EndGlobalSection
19 | GlobalSection(SolutionProperties) = preSolution
20 | HideSolutionNode = FALSE
21 | EndGlobalSection
22 | GlobalSection(ProjectConfigurationPlatforms) = postSolution
23 | {AFD1C0F1-0B77-4B0E-85E0-4E1754D7022B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
24 | {AFD1C0F1-0B77-4B0E-85E0-4E1754D7022B}.Debug|Any CPU.Build.0 = Debug|Any CPU
25 | {AFD1C0F1-0B77-4B0E-85E0-4E1754D7022B}.Release|Any CPU.ActiveCfg = Release|Any CPU
26 | {AFD1C0F1-0B77-4B0E-85E0-4E1754D7022B}.Release|Any CPU.Build.0 = Release|Any CPU
27 | {F23511D3-1558-43FE-9A05-98172BB62205}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
28 | {F23511D3-1558-43FE-9A05-98172BB62205}.Debug|Any CPU.Build.0 = Debug|Any CPU
29 | {F23511D3-1558-43FE-9A05-98172BB62205}.Release|Any CPU.ActiveCfg = Release|Any CPU
30 | {F23511D3-1558-43FE-9A05-98172BB62205}.Release|Any CPU.Build.0 = Release|Any CPU
31 | EndGlobalSection
32 | GlobalSection(NestedProjects) = preSolution
33 | {AFD1C0F1-0B77-4B0E-85E0-4E1754D7022B} = {C76BB15B-F3FF-430B-AD6C-7BD27CEA938A}
34 | {F23511D3-1558-43FE-9A05-98172BB62205} = {4CB7EB44-1422-42E0-BA49-0402DE7ED1C9}
35 | EndGlobalSection
36 | EndGlobal
37 |
--------------------------------------------------------------------------------
/src/Spector/ui-src/src/utils/formatting.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * Format JSON string with proper indentation
3 | * Handles nested JSON strings and wrapper objects
4 | */
5 | export function formatJson(jsonString: string): string {
6 | if (!jsonString || jsonString.trim() === '') {
7 | return '';
8 | }
9 |
10 | try {
11 | // First, try to parse the string as JSON
12 | let parsed = JSON.parse(jsonString);
13 |
14 | // Check if it's a wrapper object with 'content' field
15 | if (parsed && typeof parsed === 'object' && parsed.content !== undefined) {
16 | // If content is a string, try to parse it as JSON
17 | if (typeof parsed.content === 'string') {
18 | try {
19 | const innerParsed = JSON.parse(parsed.content);
20 | // Use the inner parsed content instead
21 | parsed = innerParsed;
22 | } catch {
23 | // If parsing fails, use the content string as-is
24 | return parsed.content;
25 | }
26 | } else {
27 | // If content is already an object, use it
28 | parsed = parsed.content;
29 | }
30 | }
31 |
32 | // Recursively unwrap if there are more nested JSON strings
33 | if (typeof parsed === 'string') {
34 | try {
35 | const innerParsed = JSON.parse(parsed);
36 | parsed = innerParsed;
37 | } catch {
38 | // Not JSON, return as-is
39 | return parsed;
40 | }
41 | }
42 |
43 | // Format the final parsed object
44 | return JSON.stringify(parsed, null, 2);
45 | } catch (error) {
46 | // If parsing fails, return the original string
47 | return jsonString;
48 | }
49 | }
50 |
51 | /**
52 | * Escape HTML to prevent XSS
53 | */
54 | export function escapeHtml(text: string): string {
55 | const div = document.createElement('div');
56 | div.textContent = text;
57 | return div.innerHTML;
58 | }
59 |
--------------------------------------------------------------------------------
/src/Spector/Spector.csproj:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | net8.0
5 | enable
6 | enable
7 |
8 |
9 | Spector
10 | 1.0.1
11 | Yashwanth K
12 | Yashwanth K
13 | A lightweight network and dependency inspector for ASP.NET Core applications. Spector captures HTTP traces (incoming and outgoing requests) and provides a real-time web UI for monitoring and debugging.
14 | aspnetcore;network;inspector;tracing;diagnostics;http;monitoring;debugging
15 | https://github.com/yashwanthkkn/spector
16 | https://github.com/yashwanthkkn/spector
17 | git
18 | MIT
19 | README.md
20 |
21 |
22 | false
23 | true
24 | snupkg
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
--------------------------------------------------------------------------------
/src/Spector/ui-src/src/components/TraceList/TraceGroup.tsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 | import type { Trace } from '../../types';
3 | import { ActivityItem } from './ActivityItem';
4 | import { buildActivityHierarchy } from '../../utils/hierarchy';
5 | import { formatDuration } from '../../utils/duration';
6 | import styles from './TraceGroup.module.css';
7 |
8 | interface TraceGroupProps {
9 | trace: Trace;
10 | }
11 |
12 | export function TraceGroup({ trace }: TraceGroupProps) {
13 | const [isCollapsed, setIsCollapsed] = useState(false);
14 |
15 | const totalDuration = trace.endTime.getTime() - trace.startTime.getTime();
16 | const activityCount = trace.activities.length;
17 |
18 | const { rootActivities, childrenMap } = buildActivityHierarchy(trace.activities);
19 |
20 | const toggleCollapse = () => {
21 | setIsCollapsed(!isCollapsed);
22 | };
23 |
24 | return (
25 |
26 |
30 |
31 |
Trace
32 |
{trace.traceId}
33 |
34 |
35 | {formatDuration(totalDuration)}
36 |
37 | {activityCount} request{activityCount !== 1 ? 's' : ''}
38 |
39 |
40 | ▼
41 |
42 |
43 |
44 | {!isCollapsed && (
45 |
46 | {rootActivities.map(activity => (
47 |
54 | ))}
55 |
56 | )}
57 |
58 | );
59 | }
60 |
--------------------------------------------------------------------------------
/src/Spector/ui-src/src/hooks/useSSE.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef } from 'react';
2 | import type { Activity, ConnectionStatus } from '../types';
3 |
4 | interface UseSSEOptions {
5 | url: string;
6 | onMessage: (activity: Activity) => void;
7 | onStatusChange: (status: ConnectionStatus) => void;
8 | isPaused: boolean;
9 | }
10 |
11 | export function useSSE({ url, onMessage, onStatusChange, isPaused }: UseSSEOptions) {
12 | const eventSourceRef = useRef(null);
13 | const reconnectTimeoutRef = useRef(null);
14 |
15 | useEffect(() => {
16 | function connect() {
17 | // Clean up existing connection
18 | if (eventSourceRef.current) {
19 | eventSourceRef.current.close();
20 | }
21 |
22 | const eventSource = new EventSource(url);
23 | eventSourceRef.current = eventSource;
24 |
25 | eventSource.onopen = () => {
26 | onStatusChange('connected');
27 | console.log('SSE Connected');
28 | };
29 |
30 | eventSource.onmessage = (event) => {
31 | if (isPaused) return;
32 |
33 | try {
34 | const activity: Activity = JSON.parse(event.data);
35 | console.log(activity);
36 | onMessage(activity);
37 | } catch (error) {
38 | console.error('Error parsing SSE data:', error);
39 | }
40 | };
41 |
42 | eventSource.onerror = () => {
43 | onStatusChange('disconnected');
44 | console.error('SSE Error');
45 |
46 | // Attempt to reconnect after 3 seconds
47 | if (reconnectTimeoutRef.current) {
48 | clearTimeout(reconnectTimeoutRef.current);
49 | }
50 |
51 | reconnectTimeoutRef.current = window.setTimeout(() => {
52 | if (eventSource.readyState === EventSource.CLOSED) {
53 | connect();
54 | }
55 | }, 3000);
56 | };
57 | }
58 |
59 | connect();
60 |
61 | // Cleanup on unmount
62 | return () => {
63 | if (eventSourceRef.current) {
64 | eventSourceRef.current.close();
65 | }
66 | if (reconnectTimeoutRef.current) {
67 | clearTimeout(reconnectTimeoutRef.current);
68 | }
69 | };
70 | }, [url, onMessage, onStatusChange, isPaused]);
71 | }
72 |
--------------------------------------------------------------------------------
/tests/NetworkInspector.TestApi/Controllers/TestController.cs:
--------------------------------------------------------------------------------
1 | using Microsoft.AspNetCore.Mvc;
2 |
3 | namespace NetworkInspector.TestApi.Controllers
4 | {
5 | [ApiController]
6 | [Route("[controller]")]
7 | public class TestController : ControllerBase
8 | {
9 | private readonly IHttpClientFactory _httpClientFactory;
10 |
11 | public TestController(IHttpClientFactory httpClientFactory)
12 | {
13 | _httpClientFactory = httpClientFactory;
14 | }
15 |
16 | [HttpGet("ping")]
17 | public IActionResult Ping()
18 | {
19 | return Ok(new { Message = "Pong", Time = DateTime.UtcNow });
20 | }
21 |
22 | [HttpPost("echo")]
23 | public IActionResult Echo([FromBody] object data)
24 | {
25 | return Ok(data);
26 | }
27 |
28 | [HttpGet("http-call")]
29 | public async Task MakeHttpCall()
30 | {
31 | // Call a public API using HttpClientFactory for proper Activity tracking
32 | try
33 | {
34 | var httpClient = _httpClientFactory.CreateClient("my-client");
35 | var response = await httpClient.GetAsync("https://jsonplaceholder.typicode.com/todos/1");
36 | var content = await response.Content.ReadAsStringAsync();
37 | return Ok(new { Status = response.StatusCode, Content = content });
38 | }
39 | catch (Exception ex)
40 | {
41 | return StatusCode(500, new { Error = ex.Message });
42 | }
43 | }
44 |
45 | [HttpGet("http-call1")]
46 | public async Task MakeHttpCall1()
47 | {
48 | // Call a public API using HttpClientFactory for proper Activity tracking
49 | try
50 | {
51 | var httpClient = _httpClientFactory.CreateClient("my-client");
52 | var response = await httpClient.GetAsync("https://jsonplaceholder.typicode.com/todos/1");
53 | var response1 = await httpClient.GetAsync("https://jsonplaceholsdsdder.typicode.com/todos/1");
54 | var content = await response.Content.ReadAsStringAsync();
55 | return Ok(new { Status = response.StatusCode, Content = content });
56 | }
57 | catch (Exception ex)
58 | {
59 | return StatusCode(500, new { Error = ex.Message });
60 | }
61 | }
62 |
63 | [HttpGet("error")]
64 | public IActionResult Error()
65 | {
66 | return StatusCode(500, new { Error = "Something went wrong" });
67 | }
68 | }
69 | }
70 |
--------------------------------------------------------------------------------
/src/Spector/ui-src/src/components/DetailsPanel/DetailsPanel.module.css:
--------------------------------------------------------------------------------
1 | .detailsPanel {
2 | width: 0;
3 | background: var(--bg-secondary);
4 | border-left: 1px solid var(--border-color);
5 | overflow: hidden;
6 | transition: width 0.3s;
7 | }
8 |
9 | .detailsPanel.open {
10 | width: 450px;
11 | }
12 |
13 | .detailsHeader {
14 | padding: 0.75rem 1rem;
15 | background: var(--bg-tertiary);
16 | border-bottom: 1px solid var(--border-color);
17 | display: flex;
18 | justify-content: space-between;
19 | align-items: center;
20 | }
21 |
22 | .detailsHeader h3 {
23 | font-size: 0.875rem;
24 | font-weight: 600;
25 | }
26 |
27 | .closeBtn {
28 | background: none;
29 | border: none;
30 | color: var(--text-secondary);
31 | font-size: 1.25rem;
32 | cursor: pointer;
33 | padding: 0;
34 | width: 26px;
35 | height: 26px;
36 | display: flex;
37 | align-items: center;
38 | justify-content: center;
39 | border-radius: 4px;
40 | transition: all 0.2s;
41 | }
42 |
43 | .closeBtn:hover {
44 | background: var(--bg-hover);
45 | color: var(--text-primary);
46 | }
47 |
48 | .detailsContent {
49 | padding: 1rem;
50 | overflow-y: auto;
51 | height: calc(100vh - 100px);
52 | }
53 |
54 | .detailsPlaceholder {
55 | color: var(--text-secondary);
56 | text-align: center;
57 | padding: 2rem;
58 | }
59 |
60 | .detailSection {
61 | margin-bottom: 1.25rem;
62 | }
63 |
64 | .detailSection h4 {
65 | font-size: 0.6875rem;
66 | font-weight: 600;
67 | text-transform: uppercase;
68 | letter-spacing: 0.5px;
69 | color: var(--text-secondary);
70 | margin-bottom: 0.625rem;
71 | }
72 |
73 | .detailRow {
74 | display: flex;
75 | padding: 0.375rem 0;
76 | border-bottom: 1px solid var(--border-color);
77 | font-size: 0.8125rem;
78 | }
79 |
80 | .detailRow:last-child {
81 | border-bottom: none;
82 | }
83 |
84 | .detailLabel {
85 | width: 100px;
86 | color: var(--text-secondary);
87 | flex-shrink: 0;
88 | font-size: 0.75rem;
89 | }
90 |
91 | .detailValue {
92 | flex: 1;
93 | color: var(--text-primary);
94 | word-break: break-all;
95 | font-family: 'Courier New', monospace;
96 | font-size: 0.75rem;
97 | }
98 |
99 | .codeBlock {
100 | background: var(--bg-primary);
101 | border: 1px solid var(--border-color);
102 | border-radius: 6px;
103 | padding: 0.75rem;
104 | overflow-x: auto;
105 | font-family: 'Courier New', monospace;
106 | font-size: 0.75rem;
107 | line-height: 1.5;
108 | max-height: 400px;
109 | overflow-y: auto;
110 | }
111 |
112 | .codeBlock pre {
113 | margin: 0;
114 | white-space: pre-wrap;
115 | word-wrap: break-word;
116 | }
117 |
118 | .activityMethod {
119 | font-size: 0.6875rem;
120 | padding: 0.0625rem 0.375rem;
121 | border-radius: 3px;
122 | font-weight: 600;
123 | background: var(--bg-tertiary);
124 | }
125 |
126 | .methodGET {
127 | color: var(--accent-green);
128 | }
129 |
130 | .methodPOST {
131 | color: var(--accent-blue);
132 | }
133 |
134 | .methodPUT {
135 | color: var(--accent-orange);
136 | }
137 |
138 | .methodDELETE {
139 | color: var(--accent-red);
140 | }
141 |
142 | @media (max-width: 768px) {
143 | .detailsPanel.open {
144 | position: absolute;
145 | right: 0;
146 | top: 0;
147 | height: 100%;
148 | width: 100%;
149 | z-index: 20;
150 | }
151 | }
--------------------------------------------------------------------------------
/src/Spector/ui-src/README.md:
--------------------------------------------------------------------------------
1 | # Spector UI (React + Vite)
2 |
3 | This directory contains the React-based UI for the Spector network inspector.
4 |
5 | ## Development
6 |
7 | ### Prerequisites
8 | - Node.js 20.19+ or 22.12+ (recommended)
9 | - npm
10 |
11 | ### Getting Started
12 |
13 | 1. **Install dependencies**:
14 | ```bash
15 | npm install
16 | ```
17 |
18 | 2. **Start development server**:
19 | ```bash
20 | npm run dev
21 | ```
22 |
23 | The dev server will start at `http://localhost:5173` and proxy `/spector` requests to your .NET application (assumed to be running on `http://localhost:5000`).
24 |
25 | 3. **Make sure your .NET application is running**:
26 | ```bash
27 | cd ..
28 | dotnet run
29 | ```
30 |
31 | ### Building for Production
32 |
33 | ```bash
34 | npm run build
35 | ```
36 |
37 | This will compile the React app and output the production build to `../wwwroot/`.
38 |
39 | ## Project Structure
40 |
41 | ```
42 | ui-src/
43 | ├── src/
44 | │ ├── components/ # React components
45 | │ │ ├── Header/ # Header with connection status
46 | │ │ ├── Sidebar/ # Filters and statistics
47 | │ │ ├── TraceList/ # Trace groups and activities
48 | │ │ ├── DetailsPanel/ # Activity details panel
49 | │ │ └── common/ # Reusable components
50 | │ ├── context/ # React Context for state management
51 | │ ├── hooks/ # Custom React hooks (SSE, etc.)
52 | │ ├── types/ # TypeScript type definitions
53 | │ ├── utils/ # Utility functions
54 | │ ├── styles/ # Global styles and CSS variables
55 | │ ├── App.tsx # Main app component
56 | │ └── main.tsx # Entry point
57 | ├── index.html # HTML template
58 | ├── vite.config.ts # Vite configuration
59 | ├── tsconfig.json # TypeScript configuration
60 | └── package.json # Dependencies and scripts
61 | ```
62 |
63 | ## Key Features
64 |
65 | - **Real-time Updates**: SSE connection with auto-reconnect
66 | - **Component-Based**: Modular React components with TypeScript
67 | - **CSS Modules**: Scoped styling for each component
68 | - **State Management**: React Context for global state
69 | - **Type Safety**: Full TypeScript support
70 |
71 | ## Development Workflow
72 |
73 | 1. Make changes to React components in `src/`
74 | 2. Vite will hot-reload your changes automatically
75 | 3. Test with your .NET application running
76 | 4. Build for production when ready
77 |
78 | ## Build Integration
79 |
80 | The React app is automatically built when you build the .NET project:
81 |
82 | ```bash
83 | cd ..
84 | dotnet build
85 | ```
86 |
87 | This will:
88 | 1. Run `npm install` (if needed)
89 | 2. Run `npm run build`
90 | 3. Embed the output in the .NET assembly
91 |
92 | ## Troubleshooting
93 |
94 | ### Port Conflicts
95 | If port 5173 is already in use, you can change it in `vite.config.ts`:
96 | ```typescript
97 | server: {
98 | port: 3000, // Change to your preferred port
99 | // ...
100 | }
101 | ```
102 |
103 | ### Proxy Issues
104 | If the SSE endpoint is on a different port, update the proxy target in `vite.config.ts`:
105 | ```typescript
106 | proxy: {
107 | '/spector': {
108 | target: 'http://localhost:YOUR_PORT',
109 | changeOrigin: true
110 | }
111 | }
112 | ```
113 |
114 | ### Node Version Warning
115 | If you see a Node.js version warning, it's safe to ignore. The app will still work with Node.js 20.15.1+, though 20.19+ is recommended.
116 |
--------------------------------------------------------------------------------
/src/Spector/Handlers/SpectorHttpHandler.cs:
--------------------------------------------------------------------------------
1 | using System.Diagnostics;
2 | using System.Text;
3 | using System.Text.Json;
4 | using Microsoft.Extensions.Logging;
5 | using Spector.Config;
6 |
7 | namespace Spector.Handlers;
8 |
9 | public class SpectorHttpHandler : DelegatingHandler
10 | {
11 | private readonly ActivitySource _activitySource;
12 | private readonly ILogger _logger;
13 |
14 | public SpectorHttpHandler(ActivitySource activitySource, SpectorOptions opts, ILogger logger)
15 | {
16 | _activitySource = activitySource;
17 | _logger = logger;
18 | }
19 |
20 | protected override async Task SendAsync(HttpRequestMessage request,
21 | CancellationToken cancellationToken)
22 | {
23 | using var activity = _activitySource.StartActivity("HttpOut");
24 |
25 | if (activity != null)
26 | {
27 | activity.AddTag("spector.type", "http");
28 | activity.AddTag("spector.url", request.RequestUri?.ToString());
29 | activity.AddTag("spector.method", request.Method.ToString());
30 |
31 | // Capture request body if present
32 | if (request.Content != null)
33 | {
34 | var requestBody = await request.Content.ReadAsStringAsync(cancellationToken);
35 | if (!string.IsNullOrEmpty(requestBody))
36 | {
37 | activity.AddTag("spector.requestBody", requestBody);
38 | }
39 |
40 | // Reset the content stream so it can be read again
41 | if (request.Content is StringContent || request.Content is ByteArrayContent)
42 | {
43 | // For StringContent and ByteArrayContent, we need to recreate it
44 | var contentType = request.Content.Headers.ContentType;
45 | request.Content = new StringContent(requestBody, Encoding.UTF8,
46 | contentType?.MediaType ?? "application/json");
47 | }
48 | }
49 | }
50 |
51 | HttpResponseMessage response;
52 |
53 | try
54 | {
55 | // Send the request
56 | response = await base.SendAsync(request, cancellationToken);
57 |
58 | if (activity != null)
59 | {
60 | // Add response details to activity
61 | activity.AddTag("spector.status", ((int)response.StatusCode).ToString());
62 |
63 | // Capture response body
64 | if (response.Content != null)
65 | {
66 | var responseBody = await response.Content.ReadAsStringAsync(cancellationToken);
67 | if (!string.IsNullOrEmpty(responseBody))
68 | {
69 | activity.AddTag("spector.responseBody", responseBody);
70 | }
71 |
72 | // Reset the content stream so it can be read by the caller
73 | var contentBytes = Encoding.UTF8.GetBytes(responseBody);
74 | response.Content = new ByteArrayContent(contentBytes);
75 | response.Content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue(
76 | response.Content.Headers.ContentType?.MediaType ?? "application/json");
77 | }
78 | }
79 | }
80 | catch (Exception ex)
81 | {
82 | if (activity != null)
83 | {
84 | activity.AddTag("spector.status", "500");
85 | activity.AddTag("spector.responseBody", JsonSerializer.Serialize(new { message = ex.Message }));
86 | }
87 |
88 | throw;
89 | }
90 |
91 | return response;
92 | }
93 | }
--------------------------------------------------------------------------------
/src/Spector/Service/ActivityCollectorService.cs:
--------------------------------------------------------------------------------
1 | using System.Diagnostics;
2 | using System.Threading.Channels;
3 | using Microsoft.Extensions.Hosting;
4 | using Microsoft.Extensions.Logging;
5 | using Spector.Config;
6 | using Spector.Models;
7 | using Spector.Storage;
8 |
9 | namespace Spector.Service;
10 |
11 | public class ActivityCollectorService : BackgroundService
12 | {
13 | private readonly Channel _channel;
14 | private readonly ActivityListener _listener;
15 | private readonly InMemoryTraceStore _store;
16 | private readonly ILogger _logger;
17 | private readonly SpectorOptions _opts;
18 |
19 | public ActivityCollectorService(InMemoryTraceStore store, ILogger logger, SpectorOptions opts)
20 | {
21 | _store = store;
22 | _logger = logger;
23 | _opts = opts;
24 |
25 | var options = new BoundedChannelOptions(opts.CollectorChannelCapacity)
26 | {
27 | SingleReader = true,
28 | SingleWriter = false,
29 | FullMode = BoundedChannelFullMode.DropOldest
30 | };
31 |
32 | _channel = Channel.CreateBounded(options);
33 |
34 | _listener = new ActivityListener
35 | {
36 | ShouldListenTo = src => src.Name == opts.ActivitySourceName,
37 | Sample = (ref ActivityCreationOptions _) => ActivitySamplingResult.AllDataAndRecorded,
38 | ActivityStarted = activity => { /* cheap */ },
39 | ActivityStopped = activity =>
40 | {
41 | // Best practice: map needed fields immediately rather than holding Activity instance.
42 | // But we will enqueue the Activity reference for quick mapping in the consumer.
43 | if (!_channel.Writer.TryWrite(activity))
44 | {
45 | // drop — keep diagnostic info limited
46 | }
47 | }
48 | };
49 |
50 | ActivitySource.AddActivityListener(_listener);
51 | }
52 |
53 | protected override async Task ExecuteAsync(CancellationToken stoppingToken)
54 | {
55 | await foreach (var activity in _channel.Reader.ReadAllAsync(stoppingToken))
56 | {
57 | try
58 | {
59 | if (activity.OperationName == "Microsoft.AspNetCore.Hosting.HttpRequestIn")
60 | continue;
61 | var dto = MapActivity(activity);
62 | _store.Add(dto);
63 | }
64 | catch (Exception ex)
65 | {
66 | _logger.LogError(ex, "Failed to map or store activity");
67 | }
68 | }
69 | }
70 |
71 | private TraceDto MapActivity(Activity a)
72 | {
73 | var tags = new Dictionary();
74 | foreach (var t in a.Tags)
75 | {
76 | if(t.Key.Contains("spector"))
77 | tags[t.Key] = t.Value;
78 | }
79 |
80 | var eventsList = new List();
81 | foreach (var ev in a.Events)
82 | {
83 | var et = new Dictionary();
84 | foreach (var kv in ev.Tags) et[kv.Key] = kv.Value?.ToString() ?? "";
85 | eventsList.Add(new TraceEventDto { Name = ev.Name, Timestamp = ev.Timestamp, Tags = et });
86 | }
87 |
88 | return new TraceDto
89 | {
90 | Name = a.DisplayName ?? string.Empty,
91 | TraceId = a.TraceId.ToString(),
92 | SpanId = a.SpanId.ToString(),
93 | ParentSpanId = a.ParentSpanId.ToString(),
94 | StartTimeUtc = a.StartTimeUtc,
95 | Duration = a.Duration,
96 | Kind = a.Kind.ToString(),
97 | Tags = tags,
98 | Events = eventsList
99 | };
100 | }
101 | }
--------------------------------------------------------------------------------
/src/Spector/ui-src/src/components/TraceList/ActivityItem.module.css:
--------------------------------------------------------------------------------
1 | .activityItem {
2 | display: flex;
3 | align-items: center;
4 | padding: 0.5rem 0.75rem;
5 | margin: 0.125rem 0;
6 | background: var(--bg-primary);
7 | border: 1px solid var(--border-color);
8 | border-radius: 4px;
9 | cursor: pointer;
10 | transition: all 0.2s;
11 | position: relative;
12 | }
13 |
14 | .activityItem:hover {
15 | background: var(--bg-tertiary);
16 | border-color: var(--accent-blue);
17 | transform: translateX(2px);
18 | }
19 |
20 | .activityItem.selected {
21 | background: var(--bg-tertiary);
22 | border-color: var(--accent-blue);
23 | box-shadow: 0 0 0 1px rgba(74, 158, 255, 0.2);
24 | }
25 |
26 | .activityItem.child {
27 | margin-left: 1.5rem;
28 | }
29 |
30 | .activityItem.child::before {
31 | content: '';
32 | position: absolute;
33 | left: -0.75rem;
34 | top: 50%;
35 | width: 0.75rem;
36 | height: 1px;
37 | background: var(--border-color);
38 | }
39 |
40 | .activityType {
41 | width: 6px;
42 | height: 6px;
43 | border-radius: 50%;
44 | margin-right: 0.625rem;
45 | flex-shrink: 0;
46 | }
47 |
48 | .activityType.httpin {
49 | background: var(--http-in);
50 | box-shadow: 0 0 6px var(--http-in);
51 | }
52 |
53 | .activityType.httpout {
54 | background: var(--http-out);
55 | box-shadow: 0 0 6px var(--http-out);
56 | }
57 |
58 | .activityContent {
59 | flex: 1;
60 | min-width: 0;
61 | }
62 |
63 | .activityName {
64 | font-size: 0.75rem;
65 | font-weight: 600;
66 | margin-bottom: 0.125rem;
67 | display: flex;
68 | align-items: center;
69 | gap: 0.375rem;
70 | }
71 |
72 | .activityMethod {
73 | font-size: 0.6875rem;
74 | padding: 0.0625rem 0.375rem;
75 | border-radius: 3px;
76 | font-weight: 600;
77 | background: var(--bg-tertiary);
78 | }
79 |
80 | .methodGET {
81 | color: var(--accent-green);
82 | }
83 |
84 | .methodPOST {
85 | color: var(--accent-blue);
86 | }
87 |
88 | .methodPUT {
89 | color: var(--accent-orange);
90 | }
91 |
92 | .methodDELETE {
93 | color: var(--accent-red);
94 | }
95 |
96 | .activityStatus {
97 | font-size: 0.6875rem;
98 | padding: 0.0625rem 0.375rem;
99 | border-radius: 3px;
100 | font-weight: 600;
101 | }
102 |
103 | .statusSuccess {
104 | background: rgba(52, 211, 153, 0.15);
105 | color: var(--accent-green);
106 | }
107 |
108 | .statusRedirect {
109 | background: rgba(74, 158, 255, 0.15);
110 | color: var(--accent-blue);
111 | }
112 |
113 | .statusClientError {
114 | background: rgba(251, 146, 60, 0.15);
115 | color: var(--accent-orange);
116 | }
117 |
118 | .statusServerError {
119 | background: rgba(248, 113, 113, 0.15);
120 | color: var(--accent-red);
121 | }
122 |
123 | .statusError {
124 | background: rgba(248, 113, 113, 0.2);
125 | color: var(--accent-red);
126 | font-weight: 700;
127 | }
128 |
129 | .activityUrl {
130 | font-size: 0.6875rem;
131 | color: var(--text-secondary);
132 | white-space: nowrap;
133 | overflow: hidden;
134 | text-overflow: ellipsis;
135 | }
136 |
137 | .activityTiming {
138 | display: flex;
139 | flex-direction: column;
140 | align-items: flex-end;
141 | gap: 0.25rem;
142 | margin-left: 0.5rem;
143 | }
144 |
145 | .activityDuration {
146 | font-size: 0.6875rem;
147 | font-weight: 600;
148 | color: var(--accent-orange);
149 | }
150 |
151 | .activityTimeline {
152 | width: 80px;
153 | height: 3px;
154 | background: var(--bg-tertiary);
155 | border-radius: 2px;
156 | overflow: hidden;
157 | position: relative;
158 | }
159 |
160 | .timelineBar {
161 | height: 100%;
162 | background: linear-gradient(90deg, var(--accent-blue), var(--accent-purple));
163 | border-radius: 2px;
164 | transition: width 0.3s;
165 | }
--------------------------------------------------------------------------------
/src/Spector/ui-src/src/components/TraceList/ActivityItem.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import type { Activity } from '../../types';
3 | import { useSpector } from '../../context/SpectorContext';
4 | import { parseDuration, formatDuration } from '../../utils/duration';
5 | import styles from './ActivityItem.module.css';
6 |
7 | interface ActivityItemProps {
8 | activity: Activity;
9 | children?: Activity[];
10 | traceStartTime: Date;
11 | traceDuration: number;
12 | isChild?: boolean;
13 | }
14 |
15 | export function ActivityItem({
16 | activity,
17 | children = [],
18 | traceStartTime,
19 | traceDuration,
20 | isChild = false
21 | }: ActivityItemProps) {
22 | const { selectActivity, selectedActivity, filters } = useSpector();
23 |
24 | const type = activity.Name.toLowerCase();
25 | const method = activity.Tags['spector.method'] || 'N/A';
26 | const url = activity.Tags['spector.url'] || 'N/A';
27 | const status = activity.Tags['spector.status'] || '';
28 | const error = activity.Tags['spector.error'] || '';
29 | const duration = parseDuration(activity.Duration);
30 |
31 | // Check filters
32 | if (type === 'httpin' && !filters.httpIn) return null;
33 | if (type === 'httpout' && !filters.httpOut) return null;
34 |
35 | // Calculate timeline position and width
36 | const timelineWidth = traceDuration > 0 ? (duration / traceDuration) * 100 : 100;
37 |
38 | // Determine status color
39 | let statusClass = '';
40 | let statusDisplay = status;
41 |
42 | if (error || status === '0') {
43 | statusClass = styles.statusError;
44 | statusDisplay = 'ERROR';
45 | } else if (status) {
46 | const statusCode = parseInt(status);
47 | if (statusCode >= 200 && statusCode < 300) statusClass = styles.statusSuccess;
48 | else if (statusCode >= 300 && statusCode < 400) statusClass = styles.statusRedirect;
49 | else if (statusCode >= 400 && statusCode < 500) statusClass = styles.statusClientError;
50 | else if (statusCode >= 500) statusClass = styles.statusServerError;
51 | }
52 |
53 | const isSelected = selectedActivity?.SpanId === activity.SpanId;
54 |
55 | const handleClick = (e: React.MouseEvent) => {
56 | e.stopPropagation();
57 | selectActivity(activity.SpanId);
58 | };
59 |
60 | return (
61 | <>
62 |
66 |
67 |
68 |
69 | {method}
70 | {status && {statusDisplay} }
71 |
72 |
{url}
73 |
74 |
75 |
{formatDuration(duration)}
76 |
79 |
80 |
81 | {children.map(child => (
82 |
90 | ))}
91 | >
92 | );
93 | }
94 |
--------------------------------------------------------------------------------
/src/Spector/ui-src/src/assets/react.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/src/Spector/Middleware/HttpActivityMiddleware.cs:
--------------------------------------------------------------------------------
1 | using System.Diagnostics;
2 | using Microsoft.AspNetCore.Http;
3 | using Microsoft.AspNetCore.Http.Extensions;
4 | using Spector.Config;
5 |
6 | namespace Spector.Middleware;
7 | public class HttpActivityMiddleware
8 | {
9 | private readonly RequestDelegate _next;
10 | private readonly ActivitySource _activitySource;
11 | private readonly SpectorOptions _opts;
12 |
13 | public HttpActivityMiddleware(RequestDelegate next,ActivitySource activitySource, SpectorOptions opts)
14 | {
15 | _activitySource = activitySource;
16 | _opts = opts;
17 | _next = next;
18 | }
19 |
20 | public async Task InvokeAsync(HttpContext context)
21 | {
22 | if (context.Request.Path.StartsWithSegments("/spector"))
23 | {
24 | await _next(context);
25 | return;
26 | }
27 | using var activity = _activitySource.StartActivity("HttpIn");
28 |
29 | if (activity != null)
30 | {
31 | string? requestBody = null;
32 | string? responseBody = null;
33 |
34 | try
35 | {
36 | // Enable request body buffering to allow multiple reads
37 | if (_opts.RecordRequestBodies)
38 | {
39 | context.Request.EnableBuffering();
40 |
41 | // Read the request body
42 | using var reader = new StreamReader(context.Request.Body, leaveOpen: true);
43 | requestBody = await reader.ReadToEndAsync();
44 |
45 | // Reset the stream position for the next middleware
46 | context.Request.Body.Position = 0;
47 | }
48 |
49 | // Capture response body by replacing the response stream
50 | Stream originalResponseBody = context.Response.Body;
51 |
52 | if (_opts.RecordResponseBodies)
53 | {
54 | using var responseBodyStream = new MemoryStream();
55 | context.Response.Body = responseBodyStream;
56 |
57 | try
58 | {
59 | await _next(context);
60 |
61 | // Read the response body
62 | responseBodyStream.Position = 0;
63 | using var reader = new StreamReader(responseBodyStream);
64 | responseBody = await reader.ReadToEndAsync();
65 |
66 | // Copy the response back to the original stream
67 | responseBodyStream.Position = 0;
68 | await responseBodyStream.CopyToAsync(originalResponseBody);
69 | }
70 | finally
71 | {
72 | context.Response.Body = originalResponseBody;
73 | }
74 | }
75 | else
76 | {
77 | await _next(context);
78 | }
79 |
80 | // Add tags after processing
81 | activity.AddTag("spector.type", "http");
82 | activity.AddTag("spector.url", context.Request.Path);
83 | activity.AddTag("spector.method", context.Request.Method);
84 |
85 | if (_opts.RecordRequestBodies && requestBody != null)
86 | {
87 | activity.AddTag("spector.requestBody", requestBody);
88 | }
89 |
90 | if (_opts.RecordResponseBodies && responseBody != null)
91 | {
92 | activity.AddTag("spector.responseBody", responseBody);
93 | }
94 |
95 | activity.AddTag("spector.status", context.Response.StatusCode.ToString());
96 | }
97 | catch (Exception ex)
98 | {
99 | throw;
100 | }
101 | }
102 | else
103 | {
104 | await _next(context);
105 | }
106 | }
107 | }
--------------------------------------------------------------------------------
/src/Spector/README.md:
--------------------------------------------------------------------------------
1 | # Spector
2 |
3 | A lightweight network and dependency inspector for ASP.NET Core applications. Spector captures HTTP traces (incoming and outgoing requests) and provides a real-time web UI for monitoring and debugging.
4 |
5 | ## Features
6 |
7 | - 🔍 **HTTP Request Tracing** - Automatically captures incoming and outgoing HTTP requests
8 | - 📊 **Real-time Monitoring** - Live updates via Server-Sent Events (SSE)
9 | - 🎨 **Embedded Web UI** - Beautiful, responsive interface for viewing traces
10 | - 🔗 **Dependency Tracking** - Visualize request/response chains and dependencies
11 | - 📦 **Zero Configuration** - Works out of the box with sensible defaults
12 | - 🚀 **Lightweight** - Minimal performance overhead
13 |
14 | ## Installation
15 |
16 | Install the Spector NuGet package:
17 |
18 | ```bash
19 | dotnet add package Spector
20 | ```
21 |
22 | Or via Package Manager Console:
23 |
24 | ```powershell
25 | Install-Package Spector
26 | ```
27 |
28 | ## Quick Start
29 |
30 | ### 1. Add Spector to your services
31 |
32 | In your `Program.cs` or `Startup.cs`:
33 |
34 | ```csharp
35 | using Spector;
36 |
37 | var builder = WebApplication.CreateBuilder(args);
38 |
39 | // Add Spector services
40 | builder.Services.AddSpector();
41 |
42 | // ... other service registrations
43 |
44 | var app = builder.Build();
45 |
46 | // Use Spector middleware
47 | app.UseSpector();
48 |
49 | // ... other middleware
50 |
51 | app.Run();
52 | ```
53 |
54 | ### 2. Access the UI
55 |
56 | Run your application and navigate to:
57 |
58 | ```
59 | http://localhost:/local-insights
60 | ```
61 |
62 | You'll see a real-time dashboard showing all HTTP traces captured by your application.
63 |
64 | ## Configuration
65 |
66 | Spector works with zero configuration, but you can customize it if needed:
67 |
68 | ```csharp
69 | builder.Services.AddSpector();
70 | ```
71 |
72 | ### Available Options
73 |
74 | The `SpectorOptions` class provides the following configuration:
75 |
76 | - **`UiPath`** - Custom path for the UI (default: `/local-insights`)
77 | - **`SseEndpoint`** - Custom SSE endpoint path (default: `/local-insights/events`)
78 | - **`ActivitySourceName`** - Activity source name for tracing (default: `Spector`)
79 | - **`InMemoryMaxTraces`** - Maximum number of traces to keep in memory (default: 100)
80 |
81 | To customize options, modify the `SpectorOptions` instance after registration:
82 |
83 | ```csharp
84 | builder.Services.AddSpector();
85 | builder.Services.Configure(options =>
86 | {
87 | options.UiPath = "/my-custom-path";
88 | options.InMemoryMaxTraces = 200;
89 | });
90 | ```
91 |
92 | ## How It Works
93 |
94 | Spector uses ASP.NET Core's built-in diagnostics features:
95 |
96 | 1. **Activity Tracing** - Leverages `System.Diagnostics.Activity` for distributed tracing
97 | 2. **Middleware** - Captures incoming HTTP requests via middleware
98 | 3. **HTTP Handler** - Intercepts outgoing HTTP calls via `IHttpMessageHandlerBuilderFilter`
99 | 4. **In-Memory Storage** - Stores recent traces in memory for quick access
100 | 5. **SSE Streaming** - Pushes updates to the UI in real-time
101 |
102 | ## What Gets Captured
103 |
104 | For each HTTP request/response, Spector captures:
105 |
106 | - **Request Details**
107 | - HTTP method (GET, POST, etc.)
108 | - Full URL
109 | - Headers
110 | - Request body (when available)
111 | - Timestamp
112 |
113 | - **Response Details**
114 | - Status code
115 | - Headers
116 | - Response body (when available)
117 | - Duration
118 |
119 | - **Trace Context**
120 | - Trace ID
121 | - Span ID
122 | - Parent-child relationships
123 |
124 | ## Use Cases
125 |
126 | - **Development** - Debug API calls and inspect request/response data
127 | - **Testing** - Verify HTTP interactions during integration tests
128 | - **Troubleshooting** - Identify slow dependencies or failing requests
129 | - **Learning** - Understand how your application communicates with external services
130 |
131 | ## Requirements
132 |
133 | - .NET 8.0 or later
134 | - ASP.NET Core application
135 |
136 | ## License
137 |
138 | MIT License - see LICENSE file for details
139 |
140 | ## Contributing
141 |
142 | Contributions are welcome! Please feel free to submit issues or pull requests.
143 |
144 | ## Support
145 |
146 | For issues, questions, or feature requests, please open an issue on GitHub.
147 |
--------------------------------------------------------------------------------
/src/Spector/ui-src/src/context/SpectorContext.tsx:
--------------------------------------------------------------------------------
1 | import { createContext, useContext, useState, useCallback, type ReactNode } from 'react';
2 | import type { Activity, Trace, Filters, ConnectionStatus } from '../types';
3 | import { parseDuration } from '../utils/duration';
4 |
5 | interface SpectorState {
6 | traces: Map;
7 | activities: Map;
8 | filters: Filters;
9 | isPaused: boolean;
10 | selectedActivity: Activity | null;
11 | connectionStatus: ConnectionStatus;
12 | }
13 |
14 | interface SpectorContextType extends SpectorState {
15 | addActivity: (activity: Activity) => void;
16 | clearAll: () => void;
17 | togglePause: () => void;
18 | selectActivity: (spanId: string) => void;
19 | closeDetails: () => void;
20 | setFilter: (filter: keyof Filters, value: boolean) => void;
21 | setConnectionStatus: (status: ConnectionStatus) => void;
22 | }
23 |
24 | const SpectorContext = createContext(undefined);
25 |
26 | export function SpectorProvider({ children }: { children: ReactNode }) {
27 | const [traces, setTraces] = useState>(new Map());
28 | const [activities, setActivities] = useState>(new Map());
29 | const [filters, setFilters] = useState({ httpIn: true, httpOut: true });
30 | const [isPaused, setIsPaused] = useState(false);
31 | const [selectedActivity, setSelectedActivity] = useState(null);
32 | const [connectionStatus, setConnectionStatus] = useState('connecting');
33 |
34 | const addActivity = useCallback((activity: Activity) => {
35 | const { TraceId, SpanId } = activity;
36 |
37 | // Store activity
38 | setActivities(prev => new Map(prev).set(SpanId, activity));
39 |
40 | // Get or create trace group
41 | setTraces(prev => {
42 | const newTraces = new Map(prev);
43 |
44 | if (!newTraces.has(TraceId)) {
45 | newTraces.set(TraceId, {
46 | traceId: TraceId,
47 | activities: [],
48 | startTime: new Date(activity.StartTimeUtc),
49 | endTime: new Date(activity.StartTimeUtc)
50 | });
51 | }
52 |
53 | const trace = newTraces.get(TraceId)!;
54 | trace.activities.push(activity);
55 |
56 | // Update trace timing
57 | const activityStart = new Date(activity.StartTimeUtc);
58 | const activityEnd = new Date(activityStart.getTime() + parseDuration(activity.Duration));
59 |
60 | if (activityStart < trace.startTime) trace.startTime = activityStart;
61 | if (activityEnd > trace.endTime) trace.endTime = activityEnd;
62 |
63 | return newTraces;
64 | });
65 | }, []);
66 |
67 | const clearAll = useCallback(() => {
68 | setTraces(new Map());
69 | setActivities(new Map());
70 | setSelectedActivity(null);
71 | }, []);
72 |
73 | const togglePause = useCallback(() => {
74 | setIsPaused(prev => !prev);
75 | }, []);
76 |
77 | const selectActivity = useCallback((spanId: string) => {
78 | setActivities(prev => {
79 | const activity = prev.get(spanId);
80 | if (activity) {
81 | setSelectedActivity(activity);
82 | }
83 | return prev;
84 | });
85 | }, []);
86 |
87 | const closeDetails = useCallback(() => {
88 | setSelectedActivity(null);
89 | }, []);
90 |
91 | const setFilter = useCallback((filter: keyof Filters, value: boolean) => {
92 | setFilters(prev => ({ ...prev, [filter]: value }));
93 | }, []);
94 |
95 | const value: SpectorContextType = {
96 | traces,
97 | activities,
98 | filters,
99 | isPaused,
100 | selectedActivity,
101 | connectionStatus,
102 | addActivity,
103 | clearAll,
104 | togglePause,
105 | selectActivity,
106 | closeDetails,
107 | setFilter,
108 | setConnectionStatus
109 | };
110 |
111 | return {children} ;
112 | }
113 |
114 | export function useSpector() {
115 | const context = useContext(SpectorContext);
116 | if (context === undefined) {
117 | throw new Error('useSpector must be used within a SpectorProvider');
118 | }
119 | return context;
120 | }
121 |
--------------------------------------------------------------------------------
/src/Spector/SpectorExtensions.cs:
--------------------------------------------------------------------------------
1 | using System.Diagnostics;
2 | using System.Reflection;
3 | using System.Text.Json;
4 | using Microsoft.AspNetCore.Builder;
5 | using Microsoft.Extensions.DependencyInjection;
6 | using Microsoft.Extensions.FileProviders;
7 | using Microsoft.AspNetCore.Http;
8 | using Microsoft.Extensions.DependencyInjection.Extensions;
9 | using Microsoft.Extensions.Http;
10 | using Spector.Config;
11 | using Spector.Handlers;
12 | using Spector.Middleware;
13 | using Spector.Service;
14 | using Spector.Storage;
15 |
16 | namespace Spector;
17 |
18 | public static class SpectorExtensions
19 | {
20 | public static IServiceCollection AddSpector(this IServiceCollection services)
21 | {
22 | var opts = new SpectorOptions();
23 | services.AddSingleton(opts);
24 |
25 | services.AddSingleton(sp => new InMemoryTraceStore(opts.InMemoryMaxTraces));
26 | services.AddHostedService();
27 | services.AddSingleton(new ActivitySource(opts.ActivitySourceName));
28 | services.AddTransient();
29 | services.TryAddEnumerable(ServiceDescriptor.Singleton());
30 |
31 | return services;
32 | }
33 |
34 | public static IApplicationBuilder UseSpector(this IApplicationBuilder app)
35 | {
36 | app.UseMiddleware();
37 |
38 | var opts = app.ApplicationServices.GetRequiredService();
39 | var store = app.ApplicationServices.GetRequiredService();
40 |
41 | MapSpectorSseEndpoint(app, opts, store);
42 | MapSpectorUiEndpoint(app, opts);
43 | return app;
44 | }
45 |
46 | private static void MapSpectorUiEndpoint(this IApplicationBuilder app, SpectorOptions opts)
47 | {
48 | var assembly = Assembly.GetExecutingAssembly();
49 | IFileProvider? embeddedProvider = null;
50 | var asmNs = assembly.GetName().Name ?? string.Empty;
51 | var tryPaths = new[] { $"{asmNs}.wwwroot", "wwwroot", $"{asmNs}" };
52 |
53 | foreach (var root in tryPaths)
54 | {
55 | var provider = new EmbeddedFileProvider(assembly, root);
56 | // quick check: see if index file exists
57 | var info = provider.GetFileInfo("index.html");
58 | if (info != null && info.Exists)
59 | {
60 | embeddedProvider = provider;
61 | break;
62 | }
63 | }
64 |
65 | if (embeddedProvider == null)
66 | {
67 | // fallback: attempt non-rooted provider
68 | embeddedProvider = new EmbeddedFileProvider(assembly);
69 | }
70 |
71 | // Map the UI at opts.UiPath (e.g. "/local-insights")
72 | var uiPath = opts.UiPath?.TrimEnd('/') ?? "/local-insights";
73 |
74 | app.Map(uiPath, branch =>
75 | {
76 | // serve default file when hitting /local-insights
77 | var defaultOpts = new DefaultFilesOptions { FileProvider = embeddedProvider, RequestPath = "" };
78 | defaultOpts.DefaultFileNames.Clear();
79 | defaultOpts.DefaultFileNames.Add("index.html");
80 | branch.UseDefaultFiles(defaultOpts);
81 |
82 | branch.UseStaticFiles(new StaticFileOptions
83 | {
84 | FileProvider = embeddedProvider,
85 | RequestPath = ""
86 | });
87 |
88 | // If someone hits the folder root, and default files didn't pick it up, ensure index is returned
89 | branch.Run(async ctx =>
90 | {
91 | var file = embeddedProvider.GetFileInfo("local-insights.html");
92 | if (file.Exists)
93 | {
94 | ctx.Response.ContentType = "text/html; charset=utf-8";
95 | using var stream = file.CreateReadStream();
96 | await stream.CopyToAsync(ctx.Response.Body);
97 | }
98 | else
99 | {
100 | ctx.Response.StatusCode = 404;
101 | await ctx.Response.WriteAsync("Local Insights UI not found in assembly.");
102 | }
103 | });
104 | });
105 | }
106 |
107 | private static void MapSpectorSseEndpoint(this IApplicationBuilder app, SpectorOptions opts, InMemoryTraceStore store)
108 | {
109 | var ssePath = opts.SseEndpoint ?? "/local-insights/events";
110 | app.Map(ssePath, branch =>
111 | {
112 | branch.Run(async ctx =>
113 | {
114 | ctx.Response.Headers.Add("Content-Type", "text/event-stream");
115 |
116 | var lastIndex = -1;
117 | while (!ctx.RequestAborted.IsCancellationRequested)
118 | {
119 | var items = store.GetAll();
120 | if (items.Count - 1 > lastIndex)
121 | {
122 | for (int i = lastIndex + 1; i < items.Count; i++)
123 | {
124 | var json = JsonSerializer.Serialize(items[i]);
125 | await ctx.Response.WriteAsync($"data: {json}\n\n");
126 | await ctx.Response.Body.FlushAsync(ctx.RequestAborted);
127 | }
128 | lastIndex = items.Count - 1;
129 | }
130 |
131 | try { await Task.Delay(300, ctx.RequestAborted); } catch { break; }
132 | }
133 | });
134 | });
135 | }
136 | }
--------------------------------------------------------------------------------
/src/Spector/ui-src/src/components/DetailsPanel/DetailsPanel.tsx:
--------------------------------------------------------------------------------
1 | import { useSpector } from '../../context/SpectorContext';
2 | import { parseDuration, formatDuration } from '../../utils/duration';
3 | import { formatJson } from '../../utils/formatting';
4 | import { Badge } from '../common/Badge';
5 | import styles from './DetailsPanel.module.css';
6 |
7 | export function DetailsPanel() {
8 | const { selectedActivity, closeDetails } = useSpector();
9 |
10 | if (!selectedActivity) {
11 | return (
12 |
13 |
14 |
Request Details
15 |
16 |
17 |
Select a request to view details
18 |
19 |
20 | );
21 | }
22 |
23 | const type = selectedActivity.Name.toLowerCase() as 'httpin' | 'httpout';
24 | const method = selectedActivity.Tags['spector.method'] || 'N/A';
25 | const url = selectedActivity.Tags['spector.url'] || 'N/A';
26 | const status = selectedActivity.Tags['spector.status'] || '';
27 | const error = selectedActivity.Tags['spector.error'] || '';
28 | const errorType = selectedActivity.Tags['spector.errorType'] || '';
29 | const duration = parseDuration(selectedActivity.Duration);
30 | const requestBody = selectedActivity.Tags['spector.requestBody'] || '';
31 | const responseBody = selectedActivity.Tags['spector.responseBody'] || '';
32 |
33 | return (
34 |
35 |
36 |
Request Details
37 | ×
38 |
39 |
40 |
41 |
Overview
42 |
43 | Type
44 |
45 | {selectedActivity.Name}
46 |
47 |
48 |
49 | Method
50 |
51 | {method}
52 |
53 |
54 |
55 | URL
56 | {url}
57 |
58 | {status && status !== '0' && (
59 |
60 | Status
61 | {status}
62 |
63 | )}
64 |
65 | Duration
66 | {formatDuration(duration)}
67 |
68 |
69 |
70 | {error && (
71 |
72 |
Error Details
73 |
74 | Error Type
75 | {errorType}
76 |
77 |
78 | Error Message
79 | {error}
80 |
81 |
82 | )}
83 |
84 |
85 |
Timing
86 |
87 | Start Time
88 |
89 | {new Date(selectedActivity.StartTimeUtc).toLocaleString()}
90 |
91 |
92 |
93 | Duration
94 | {selectedActivity.Duration}
95 |
96 |
97 |
98 | {requestBody && (
99 |
100 |
Request Body
101 |
102 |
{formatJson(requestBody)}
103 |
104 |
105 | )}
106 |
107 | {responseBody && (
108 |
109 |
Response Body
110 |
111 |
{formatJson(responseBody)}
112 |
113 |
114 | )}
115 |
116 |
117 | );
118 | }
119 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Spector
2 |
3 |
4 | **A lightweight network and dependency inspector for ASP.NET Core applications**
5 |
6 | [](https://www.nuget.org/packages/Spector/)
7 | [](https://opensource.org/licenses/MIT)
8 | [](https://dotnet.microsoft.com/download)
9 |
10 | [Features](#features) • [Installation](#installation) • [Quick Start](#quick-start) • [Documentation](#documentation) • [Contributing](#contributing)
11 |
12 |
13 | ---
14 |
15 | ## 🎯 Overview
16 |
17 | Spector is a powerful yet lightweight debugging tool for ASP.NET Core applications that captures and visualizes HTTP traces in real-time. Monitor incoming requests, outgoing HTTP calls, and their dependencies through a beautiful embedded web interface—all with zero configuration required.
18 |
19 | Perfect for development, testing, and troubleshooting API interactions without the overhead of external monitoring tools.
20 |
21 | ## ✨ Features
22 |
23 | - 🔍 **Automatic HTTP Tracing** - Captures all incoming and outgoing HTTP requests automatically
24 | - 📊 **Real-time Monitoring** - Live updates via Server-Sent Events (SSE)
25 | - 🎨 **Beautiful Web UI** - Modern React-based interface embedded directly in your application
26 | - 🔗 **Dependency Visualization** - See the complete request/response chain with parent-child relationships
27 | - 📦 **Zero Configuration** - Works out of the box with sensible defaults
28 | - 🚀 **Lightweight** - Minimal performance overhead, in-memory storage
29 | - 🔐 **Development-Focused** - Designed for local development and testing environments
30 | - 📝 **Detailed Inspection** - View headers, bodies, status codes, and timing information
31 |
32 | ## 📸 Screenshots
33 |
34 | 
35 | *Real-time HTTP trace monitoring with detailed request/response inspection*
36 |
37 | ## 🚀 Installation
38 |
39 | Install the Spector NuGet package:
40 |
41 | ```bash
42 | dotnet add package Spector
43 | ```
44 |
45 | Or via Package Manager Console:
46 |
47 | ```powershell
48 | Install-Package Spector
49 | ```
50 |
51 | ## 🏁 Quick Start
52 |
53 | ### 1. Add Spector to your ASP.NET Core application
54 |
55 | In your `Program.cs`:
56 |
57 | ```csharp
58 | using Spector;
59 |
60 | var builder = WebApplication.CreateBuilder(args);
61 |
62 | // Add Spector services
63 | builder.Services.AddSpector();
64 |
65 | // ... your other service registrations
66 | builder.Services.AddControllers();
67 | builder.Services.AddHttpClient();
68 |
69 | var app = builder.Build();
70 |
71 | // Use Spector middleware (add early in the pipeline)
72 | app.UseSpector();
73 |
74 | // ... your other middleware
75 | app.UseAuthorization();
76 | app.MapControllers();
77 |
78 | app.Run();
79 | ```
80 |
81 | ### 2. Run your application
82 |
83 | ```bash
84 | dotnet run
85 | ```
86 |
87 | ### 3. Access the Spector UI
88 |
89 | Navigate to:
90 |
91 | ```
92 | http://localhost:/spector
93 | ```
94 |
95 | You'll see a real-time dashboard showing all HTTP traces captured by your application! 🎉
96 |
97 | ## 📚 Documentation
98 |
99 | ### Configuration
100 |
101 | Spector works with zero configuration, but you can customize it:
102 |
103 | ```csharp
104 | builder.Services.AddSpector();
105 | builder.Services.Configure(options =>
106 | {
107 | options.UiPath = "/my-custom-path"; // Default: "/spector"
108 | options.SseEndpoint = "/my-events"; // Default: "/spector/events"
109 | options.InMemoryMaxTraces = 200; // Default: 100
110 | options.ActivitySourceName = "MyApp"; // Default: "Spector"
111 | });
112 | ```
113 |
114 | ### What Gets Captured
115 |
116 | For each HTTP request/response, Spector captures:
117 |
118 | **Request Details:**
119 | - HTTP method (GET, POST, PUT, DELETE, etc.)
120 | - Full URL with query parameters
121 | - Request headers
122 | - Request body (when available)
123 | - Timestamp
124 |
125 | **Response Details:**
126 | - HTTP status code
127 | - Response headers
128 | - Response body (when available)
129 | - Duration/timing
130 |
131 | **Trace Context:**
132 | - Trace ID for distributed tracing
133 | - Span ID
134 | - Parent-child relationships between requests
135 |
136 | ### How It Works
137 |
138 | Spector leverages ASP.NET Core's built-in diagnostics infrastructure:
139 |
140 | 1. **Activity Tracing** - Uses `System.Diagnostics.Activity` for distributed tracing
141 | 2. **Middleware** - Captures incoming HTTP requests via custom middleware
142 | 3. **HTTP Message Handler** - Intercepts outgoing HTTP calls using `IHttpMessageHandlerBuilderFilter`
143 | 4. **In-Memory Storage** - Stores recent traces in a circular buffer
144 | 5. **SSE Streaming** - Pushes real-time updates to the web UI
145 | 6. **Embedded UI** - React-based interface bundled as embedded resources
146 |
147 | ## 🎯 Use Cases
148 |
149 | - **Local Development** - Debug API calls and inspect request/response data without external tools
150 | - **Integration Testing** - Verify HTTP interactions during automated tests
151 | - **Troubleshooting** - Identify slow dependencies, failing requests, or unexpected behavior
152 | - **Learning** - Understand how your application communicates with external services
153 | - **API Documentation** - See actual request/response examples for your endpoints
154 |
155 | ## 🛠️ Development
156 |
157 | ### Prerequisites
158 |
159 | - [.NET 8.0 SDK](https://dotnet.microsoft.com/download/dotnet/8.0)
160 | - [Node.js 18+](https://nodejs.org/) (for UI development)
161 |
162 | ### Building from Source
163 |
164 | ```bash
165 | # Clone the repository
166 | git clone https://github.com/yashwanthkkn/spector.git
167 | cd spector
168 |
169 | # Build the solution
170 | dotnet build
171 |
172 | # Run tests
173 | dotnet test
174 |
175 | # Pack the NuGet package
176 | dotnet pack src/Spector/Spector.csproj -c Release -o ./nupkg
177 | ```
178 |
179 | ### Project Structure
180 |
181 | ```
182 | network-inspector/
183 | ├── src/
184 | │ └── Spector/ # Main library
185 | │ ├── Config/ # Configuration options
186 | │ ├── Handlers/ # HTTP message handlers
187 | │ ├── Middleware/ # ASP.NET Core middleware
188 | │ ├── Models/ # Data models
189 | │ ├── Service/ # Background services
190 | │ ├── Storage/ # In-memory trace storage
191 | │ └── ui-src/ # React UI source
192 | ├── tests/
193 | │ └── NetworkInspector.TestApi/ # Test API project
194 | ├── docs/ # Documentation
195 | └── nupkg/ # Built NuGet packages
196 | ```
197 |
198 | ### UI Development
199 |
200 | The UI is built with React, TypeScript, and Vite:
201 |
202 | ```bash
203 | cd src/Spector/ui-src
204 |
205 | # Install dependencies
206 | npm install
207 |
208 | # Run dev server
209 | npm run dev
210 |
211 | # Build for production
212 | npm run build
213 | ```
214 |
215 | ## 🤝 Contributing
216 |
217 | Contributions are welcome! Please feel free to submit a Pull Request. For major changes, please open an issue first to discuss what you would like to change.
218 |
219 | ### Contribution Guidelines
220 |
221 | 1. Fork the repository
222 | 2. Create your feature branch (`git checkout -b feature/AmazingFeature`)
223 | 3. Commit your changes (`git commit -m 'Add some AmazingFeature'`)
224 | 4. Push to the branch (`git push origin feature/AmazingFeature`)
225 | 5. Open a Pull Request
226 |
227 | Please make sure to:
228 | - Update tests as appropriate
229 | - Follow the existing code style
230 | - Update documentation for any new features
231 |
232 | ## 📋 Requirements
233 |
234 | - .NET 8.0 or later
235 | - ASP.NET Core application
236 | - Modern web browser (for viewing the UI)
237 |
238 | ## ⚠️ Important Notes
239 |
240 | - **Development Use Only** - Spector is designed for development and testing environments. Do not use in production as it stores sensitive request/response data in memory.
241 | - **Memory Usage** - Traces are stored in memory with a configurable limit (default: 100 traces)
242 | - **Security** - The UI endpoint is not authenticated. Ensure it's only accessible in trusted environments.
243 |
244 | ## 📝 License
245 |
246 | This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details.
247 |
248 | ## 🙏 Acknowledgments
249 |
250 | - Built with [ASP.NET Core](https://dotnet.microsoft.com/apps/aspnet)
251 | - UI powered by [React](https://react.dev/) and [Vite](https://vitejs.dev/)
252 | - Inspired by the need for lightweight, embedded debugging tools
253 |
254 | ## 📞 Support
255 |
256 | - 🐛 **Bug Reports**: [Open an issue](https://github.com/yashwanthkkn/spector/issues)
257 | - 💡 **Feature Requests**: [Open an issue](https://github.com/yashwanthkkn/spector/issues)
258 | - 💬 **Questions**: [Start a discussion](https://github.com/yashwanthkkn/spector/discussions)
259 |
260 | ## 🗺️ Roadmap
261 |
262 | - [ ] Export traces to file (JSON, HAR format)
263 | - [ ] Filter and search capabilities
264 | - [ ] Performance metrics and analytics
265 | - [ ] Custom trace retention policies
266 | - [ ] WebSocket support
267 | - [ ] gRPC call tracing
268 | - [ ] Trace comparison tools
269 |
270 |
271 | **Made with ❤️ by [Yashwanth K](https://github.com/yashwanthkkn)**
272 |
273 | If you find Spector useful, please consider giving it a ⭐ on GitHub!
274 |
275 |
--------------------------------------------------------------------------------