diff --git a/projects/observability/src/public-api.ts b/projects/observability/src/public-api.ts index c03881ee7..9e72abaa7 100644 --- a/projects/observability/src/public-api.ts +++ b/projects/observability/src/public-api.ts @@ -413,3 +413,6 @@ export * from './shared/utils/time-range'; // CSV Downloader Service export * from './shared/services/global-csv-download/global-csv-download.service'; + +// Curl Command Generator +export * from './shared/utils/curl-command-generator/curl-command-generator-util'; diff --git a/projects/observability/src/shared/components/span-detail/span-data.ts b/projects/observability/src/shared/components/span-detail/span-data.ts index 418c2599a..099863439 100644 --- a/projects/observability/src/shared/components/span-detail/span-data.ts +++ b/projects/observability/src/shared/components/span-detail/span-data.ts @@ -18,4 +18,5 @@ export interface SpanData { exitCallsBreakup?: Dictionary; startTime?: number; logEvents?: LogEvent[]; + requestMethod?: string; } diff --git a/projects/observability/src/shared/components/span-detail/span-detail.component.scss b/projects/observability/src/shared/components/span-detail/span-detail.component.scss index a8a69b924..7d79e462d 100644 --- a/projects/observability/src/shared/components/span-detail/span-detail.component.scss +++ b/projects/observability/src/shared/components/span-detail/span-detail.component.scss @@ -11,7 +11,10 @@ display: flex; flex-direction: column; - .toggle-group { + .toggle-group-and-actions { + display: flex; + align-items: center; + gap: 8px; margin-top: 18px; } diff --git a/projects/observability/src/shared/components/span-detail/span-detail.component.ts b/projects/observability/src/shared/components/span-detail/span-detail.component.ts index 56f792111..5ba1fc63a 100644 --- a/projects/observability/src/shared/components/span-detail/span-detail.component.ts +++ b/projects/observability/src/shared/components/span-detail/span-detail.component.ts @@ -1,12 +1,14 @@ import { ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, Output } from '@angular/core'; import { IconType } from '@hypertrace/assets-library'; import { TypedSimpleChanges } from '@hypertrace/common'; -import { ToggleItem } from '@hypertrace/components'; import { isEmpty } from 'lodash-es'; import { Observable, ReplaySubject } from 'rxjs'; +import { ObservabilityIconType } from '../../icons/observability-icon-type'; import { SpanData } from './span-data'; import { SpanDetailLayoutStyle } from './span-detail-layout-style'; import { SpanDetailTab } from './span-detail-tab'; +import { CurlCommandGeneratorUtil } from '../../utils/curl-command-generator/curl-command-generator-util'; +import { ButtonSize, ToggleItem } from '@hypertrace/components'; @Component({ selector: 'ht-span-detail', @@ -27,13 +29,25 @@ import { SpanDetailTab } from './span-detail-tab';
- - + +
+ + + + +
@@ -106,6 +120,10 @@ export class SpanDetailComponent implements OnChanges { @Input() public showAttributesTab: boolean = true; + + @Input() + public showCurlCommand: boolean = false; + @Output() public readonly closed: EventEmitter = new EventEmitter(); public showRequestTab?: boolean; @@ -153,6 +171,16 @@ export class SpanDetailComponent implements OnChanges { this.activeTabSubject.next(tab); } + protected getCurlCommand = (span: SpanData): string => + CurlCommandGeneratorUtil.generateCurlCommand( + span.requestHeaders, + span.requestCookies, + span.requestBody, + span.requestUrl, + span.protocolName ?? '', + span.requestMethod ?? '', + ); + /** * Tabs are added in order: * 1. Request diff --git a/projects/observability/src/shared/components/span-detail/span-detail.module.ts b/projects/observability/src/shared/components/span-detail/span-detail.module.ts index 9269cf402..648ef995a 100644 --- a/projects/observability/src/shared/components/span-detail/span-detail.module.ts +++ b/projects/observability/src/shared/components/span-detail/span-detail.module.ts @@ -2,6 +2,7 @@ import { CommonModule } from '@angular/common'; import { NgModule } from '@angular/core'; import { ButtonModule, + CopyToClipboardModule, IconModule, JsonViewerModule, LabelModule, @@ -20,6 +21,7 @@ import { SpanRequestDetailModule } from './request/span-request-detail.module'; import { SpanResponseDetailModule } from './response/span-response-detail.module'; import { SpanDetailComponent } from './span-detail.component'; import { SpanTagsDetailModule } from './tags/span-tags-detail.module'; +import { MemoizeModule } from '@hypertrace/common'; @NgModule({ imports: [ @@ -41,6 +43,8 @@ import { SpanTagsDetailModule } from './tags/span-tags-detail.module'; LogEventsTableModule, ToggleGroupModule, MessageDisplayModule, + CopyToClipboardModule, + MemoizeModule, ], declarations: [SpanDetailComponent], exports: [SpanDetailComponent], diff --git a/projects/observability/src/shared/utils/curl-command-generator/curl-command-generator-util.test.ts b/projects/observability/src/shared/utils/curl-command-generator/curl-command-generator-util.test.ts new file mode 100644 index 000000000..339335241 --- /dev/null +++ b/projects/observability/src/shared/utils/curl-command-generator/curl-command-generator-util.test.ts @@ -0,0 +1,120 @@ +import { CurlCommandGeneratorUtil } from './curl-command-generator-util'; + +describe('generateCurlCommand', () => { + test('should generate a curl command for HTTP GET requests', () => { + const requestUrl = 'https://example.com'; + const protocol = 'HTTP'; + const methodType = 'GET'; + const requestHeaders = {}; + const requestCookies = {}; + const requestBody = ''; + + const curlCommand = CurlCommandGeneratorUtil.generateCurlCommand( + requestHeaders, + requestCookies, + requestBody, + requestUrl, + protocol, + methodType, + ); + + expect(curlCommand).toEqual(`curl -X GET https://example.com`); + }); + + test('should generate a curl command for HTTP POST requests with headers and body', () => { + const requestUrl = 'https://example.com'; + const protocol = 'HTTP'; + const methodType = 'POST'; + const requestHeaders = { + 'Content-Type': 'application/json', + Accept: 'application/json', + }; + const requestCookies = {}; + const requestBody = '{"name": "John", "age": 30}'; + + const curlCommand = CurlCommandGeneratorUtil.generateCurlCommand( + requestHeaders, + requestCookies, + requestBody, + requestUrl, + protocol, + methodType, + ); + + expect(curlCommand).toEqual( + `curl -X POST -H 'Content-Type: application/json' -H 'Accept: application/json' -d '{"name": "John", "age": 30}' https://example.com`, + ); + }); + + test('should generate a curl command for HTTP POST requests with cookies', () => { + const requestUrl = 'https://example.com'; + const protocol = 'HTTP'; + const methodType = 'POST'; + const requestHeaders = {}; + const requestCookies = { + sessionid: '123456789', + user: 'John Doe', + }; + const requestBody = '{"name": "John", "age": 30}'; + const curlCommand = CurlCommandGeneratorUtil.generateCurlCommand( + requestHeaders, + requestCookies, + requestBody, + requestUrl, + protocol, + methodType, + ); + + expect(curlCommand).toEqual( + `curl -X POST -b 'sessionid=123456789;user=John Doe' -d '{"name": "John", "age": 30}' https://example.com`, + ); + }); + + test('should generate a curl command for HTTP POST requests with headers, cookies, and body', () => { + const requestUrl = 'https://example.com'; + const protocol = 'HTTP'; + const methodType = 'POST'; + const requestHeaders = { + 'Content-Type': 'application/json', + Accept: 'application/json', + }; + const requestCookies = { + sessionid: '123456789', + user: 'John Doe', + }; + const requestBody = '{"name": "John", "age": 30}'; + + const curlCommand = CurlCommandGeneratorUtil.generateCurlCommand( + requestHeaders, + requestCookies, + requestBody, + requestUrl, + protocol, + methodType, + ); + + expect(curlCommand).toEqual( + `curl -X POST -H 'Content-Type: application/json' -H 'Accept: application/json' -b 'sessionid=123456789;user=John Doe' -d '{"name": "John", "age": 30}' https://example.com`, + ); + }); + + test('should return an error message for unsupported protocols', () => { + const requestUrl = 'https://example.com'; + const protocol = 'ftp'; + const methodType = 'POST'; + const requestHeaders = {}; + const requestCookies = {}; + const requestBody = ''; + + const curlCommand = CurlCommandGeneratorUtil.generateCurlCommand( + requestHeaders, + requestCookies, + requestBody, + requestUrl, + protocol, + methodType, + ); + + expect(curlCommand).toEqual('curl command is not supported'); + }); +}); diff --git a/projects/observability/src/shared/utils/curl-command-generator/curl-command-generator-util.ts b/projects/observability/src/shared/utils/curl-command-generator/curl-command-generator-util.ts new file mode 100644 index 000000000..6d68bfc8b --- /dev/null +++ b/projects/observability/src/shared/utils/curl-command-generator/curl-command-generator-util.ts @@ -0,0 +1,90 @@ +import { Dictionary } from '@hypertrace/common'; + +export abstract class CurlCommandGeneratorUtil { + private static readonly CURL_COMMAND_NAME: string = 'curl'; + private static readonly HEADER_OPTION: string = '-H'; + private static readonly REQUEST_OPTION: string = '-X'; + private static readonly BODY_OPTION: string = '-d'; + private static readonly COOKIES_OPTION: string = '-b'; + private static readonly SINGLE_SPACE: string = ' '; + private static readonly SINGLE_QUOTES_DELIMITER_CHAR: string = "\\'"; + private static readonly SINGLE_QUOTE_CHAR: string = "'"; + private static readonly SEMI_COLON_CHAR: string = ';'; + private static readonly COLON_CHAR: string = ':'; + private static readonly EQUALS_CHAR: string = '='; + private static readonly GET_METHOD: string = 'GET'; + private static readonly DELETE_METHOD: string = 'DELETE'; + private static readonly NOT_SUPPORTED_MESSAGE: string = 'curl command is not supported'; + + public static generateCurlCommand( + requestHeaders: Dictionary, + requestCookies: Dictionary, + requestBody: string, + requestUrl: string, + protocol: string, + methodType: string, + ): string { + let curlCommand: string = ''; + + if (protocol === Protocol.PROTOCOL_HTTP || protocol === Protocol.PROTOCOL_HTTPS) { + curlCommand += `${this.CURL_COMMAND_NAME}${this.SINGLE_SPACE}${this.REQUEST_OPTION}${this.SINGLE_SPACE}${methodType}${this.SINGLE_SPACE}`; + + if (Object.entries(requestHeaders).length > 0) { + curlCommand += `${this.getHeadersAsString(requestHeaders)}`; + } + + if (Object.entries(requestCookies).length > 0) { + curlCommand += `${this.getCookiesAsString(requestCookies)}`; + } + + // { POST, PUT, PATCH } methodType will have a body, and { GET, DELETE } will not. + if (!(methodType.includes(this.GET_METHOD) || methodType.includes(this.DELETE_METHOD))) { + curlCommand += `${this.BODY_OPTION}${this.SINGLE_SPACE}${this.SINGLE_QUOTE_CHAR}${this.getEnrichedBody( + requestBody, + )}${this.SINGLE_QUOTE_CHAR}${this.SINGLE_SPACE}`; + } + + curlCommand += requestUrl; + } else { + curlCommand += this.NOT_SUPPORTED_MESSAGE; + } + + return curlCommand; + } + + private static getHeadersAsString(requestHeaders: Dictionary): string { + return Object.entries(requestHeaders) + .map( + ([key, value]) => + `${this.HEADER_OPTION}${this.SINGLE_SPACE}${this.SINGLE_QUOTE_CHAR}${key}${this.COLON_CHAR}${this.SINGLE_SPACE}${value}${this.SINGLE_QUOTE_CHAR}${this.SINGLE_SPACE}`, + ) + .join(''); + } + + private static getCookiesAsString(requestCookies: Dictionary): string { + let cookiesString: string = ''; + + cookiesString += `${this.COOKIES_OPTION}${this.SINGLE_SPACE}${this.SINGLE_QUOTE_CHAR}`; + + Object.entries(requestCookies).forEach(([key, value]) => { + cookiesString += `${key}${this.EQUALS_CHAR}${value}${this.SEMI_COLON_CHAR}`; + }); + + if (cookiesString[cookiesString.length - 1] === this.SEMI_COLON_CHAR) { + cookiesString = cookiesString.substr(0, cookiesString.length - 1); + } + + cookiesString += `${this.SINGLE_QUOTE_CHAR}${this.SINGLE_SPACE}`; + + return cookiesString; + } + + private static getEnrichedBody(body: string): string { + return body.replaceAll(this.SINGLE_QUOTE_CHAR, this.SINGLE_QUOTES_DELIMITER_CHAR); + } +} + +const enum Protocol { + PROTOCOL_HTTP = 'HTTP', + PROTOCOL_HTTPS = 'HTTPS', +}