Log external requests (#44286)

* Added Redis access
* Added RedisLogItem with request+response info and stack traces
* Added RedisLoggerMiddleware, with sensitive header removal
* Added GraphQL API endpoint, for site admins only, matching the Cursor Connections spec
* Added admin UI with five-sec polling and "copy curl" feature
* Added back-end tests
* Added log limit setting with default=50, range: 0..500
* Added changelog item
* Deduplicated WebhookLogHeader with HTTPHeader
* Unrelated: Fixed two typos
* Use  for the API
* Made the CSS work well with both light and dark themes and made sure it's accessible. Tested with a screen reader.

Co-authored-by: Robert Lin <robert@bobheadxi.dev>
This commit is contained in:
David Veszelovszki 2022-11-29 12:08:54 +01:00 committed by GitHub
parent 5473007223
commit cd28a4cc2c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
25 changed files with 1241 additions and 92 deletions

View File

@ -21,6 +21,7 @@ All notable changes to Sourcegraph are documented in this file.
- Unindexed search now use the index for files that have not changed between the unindexed commit and the indexed commit. The result is faster unindexed search in general. If you are noticing issues you can disable by setting the feature flag `search-hybrid` to false. [#37112](https://github.com/sourcegraph/sourcegraph/issues/37112)
- The number of commits listed in the History tab can now be customized for all users by site admins under Configuration -> Global Settings from the site admin page by using the config `history.defaultPageSize`. Individual users may also set `history.defaultPagesize` from their user settings page to override the value set under the Global Settings. [#44651](https://github.com/sourcegraph/sourcegraph/pull/44651)
- Batch Changes: Mounted files can be accessed via the UI on the executions page. [#43180](https://github.com/sourcegraph/sourcegraph/pull/43180)
- Added "Outbound request log" feature for site admins [#44286](https://github.com/sourcegraph/sourcegraph/pull/44286)
### Changed

View File

@ -169,6 +169,8 @@ export interface SourcegraphContext extends Pick<Required<SiteConfiguration>, 'e
/** Prompt users with browsers that would crash to download a modern browser. */
RedirectUnsupportedBrowser?: boolean
outboundRequestLogLimit?: number
}
export interface BrandAssets {

View File

@ -0,0 +1,82 @@
@import 'wildcard/src/global-styles/breakpoints';
.requests-grid {
display: grid;
grid-template-columns: max-content max-content max-content minmax(0, auto) max-content max-content;
row-gap: 0.2rem;
column-gap: 0.5rem;
align-items: center;
@media (--sm-breakpoint-down) {
row-gap: 0.5rem;
column-gap: 0.5rem;
}
}
.url-column {
overflow: hidden;
text-overflow: ellipsis;
}
.successful {
color: var(--success);
}
.failed {
color: var(--danger);
}
.separator {
/* Make it full width in the current row. */
grid-column: 1 / -1;
border-top: 1px solid var(--border-color-2);
@media (--xs-breakpoint-down) {
margin-top: 1rem;
padding-bottom: 1rem;
}
}
.more-info {
overflow-wrap: break-word;
strong {
color: var(--primary);
}
}
.method {
&.get {
color: var(--blue);
}
&.post {
color: var(--indigo);
}
&.put {
color: var(--purple);
}
&.patch {
color: var(--pink);
}
&.delete {
color: var(--red);
}
&.head {
color: var(--orange);
}
&.options {
color: var(--yellow);
}
&.connect {
color: var(--green);
}
&.trace {
color: var(--teal);
}
}

View File

@ -0,0 +1,354 @@
import React, { ReactNode, useCallback, useEffect, useState } from 'react'
import { mdiChevronDown } from '@mdi/js'
import VisuallyHidden from '@reach/visually-hidden'
import classNames from 'classnames'
import copy from 'copy-to-clipboard'
import { RouteComponentProps } from 'react-router'
import { of } from 'rxjs'
import { delay, map } from 'rxjs/operators'
import { ErrorAlert } from '@sourcegraph/branded/src/components/alerts'
import { useQuery } from '@sourcegraph/http-client/src'
import { TelemetryProps } from '@sourcegraph/shared/src/telemetry/telemetryService'
import {
Button,
Code,
Container,
Icon,
Link,
LoadingSpinner,
PageHeader,
Popover,
PopoverContent,
PopoverTrigger,
Position,
Text,
Tooltip,
} from '@sourcegraph/wildcard'
import {
FilteredConnection,
FilteredConnectionFilter,
FilteredConnectionQueryArguments,
} from '../components/FilteredConnection'
import { PageTitle } from '../components/PageTitle'
import { Timestamp } from '../components/time/Timestamp'
import { OutboundRequestsResult, OutboundRequestsVariables } from '../graphql-operations'
import { OUTBOUND_REQUESTS, OUTBOUND_REQUESTS_PAGE_POLL_INTERVAL } from './backend'
import styles from './SiteAdminOutboundRequestsPage.module.scss'
export interface SiteAdminOutboundRequestsPageProps extends RouteComponentProps, TelemetryProps {
now?: () => Date
}
export type OutboundRequest = OutboundRequestsResult['outboundRequests']['nodes'][0]
const filters: FilteredConnectionFilter[] = [
{
id: 'filters',
label: 'Filter',
type: 'select',
values: [
{
label: 'All',
value: 'all',
tooltip: 'Show all requests',
args: {},
},
{
label: 'Failed',
value: 'failed',
tooltip: 'Show only failed requests',
args: { failed: true },
},
{
label: 'Successful',
value: 'successful',
tooltip: 'Show only successful requests',
args: { failed: false },
},
],
},
]
export const SiteAdminOutboundRequestsPage: React.FunctionComponent<
React.PropsWithChildren<SiteAdminOutboundRequestsPageProps>
> = ({ history, telemetryService }) => {
const [items, setItems] = useState<OutboundRequest[]>([])
useEffect(() => {
telemetryService.logPageView('SiteAdminOutboundRequests')
}, [telemetryService])
const lastId = items[items.length - 1]?.id ?? null
const { data, loading, error, stopPolling, refetch, startPolling } = useQuery<
OutboundRequestsResult,
OutboundRequestsVariables
>(OUTBOUND_REQUESTS, {
variables: { after: lastId },
pollInterval: OUTBOUND_REQUESTS_PAGE_POLL_INTERVAL,
})
useEffect(() => {
if (data?.outboundRequests?.nodes?.length && (!lastId || data?.outboundRequests.nodes[0].id > lastId)) {
const newItems = items
.concat(...data.outboundRequests.nodes)
.slice(
Math.max(
items.length +
data.outboundRequests.nodes.length -
(window.context.outboundRequestLogLimit ?? 0),
0
)
)
// Workaround for https://github.com/apollographql/apollo-client/issues/3053 to update the variables.
// Weirdly enough, we don't need to wait for refetch() to complete before restarting polling.
// See http://www.petecorey.com/blog/2019/09/23/apollo-quirks-polling-after-refetching-with-new-variables/
stopPolling()
setItems(newItems)
refetch({ after: newItems[newItems.length - 1]?.id ?? null })
.then(() => {})
.catch(() => {})
startPolling(OUTBOUND_REQUESTS_PAGE_POLL_INTERVAL)
}
}, [data, lastId, items, refetch, startPolling, stopPolling])
const queryOutboundRequests = useCallback(
(args: FilteredConnectionQueryArguments & { failed?: boolean }) =>
of([...items].reverse()).pipe(
delay(200), // Without this, FilteredConnection will get into an infinite loop. :facepalm:
map(items => {
const filtered = items?.filter(
request =>
(!args.query || matchesString(request, args.query)) &&
(args.failed !== false || isSuccessful(request)) &&
(args.failed !== true || !isSuccessful(request))
)
return {
nodes: filtered ?? [],
totalCount: (filtered ?? []).length,
}
})
),
[items]
)
return (
<div className="site-admin-migrations-page">
<PageTitle title="Outbound requests - Admin" />
<PageHeader
path={[{ text: 'Outbound requests' }]}
headingElement="h2"
description={
<>
This is the log of recent external requests sent by the Sourcegraph instance. Handy for seeing
what's happening between Sourcegraph and other services.{' '}
<strong>The list updates every five seconds.</strong>
</>
}
className="mb-3"
/>
<Container className="mb-3">
{error && !loading && <ErrorAlert error={error} />}
{loading && !error && <LoadingSpinner />}
{window.context.outboundRequestLogLimit ? (
<FilteredConnection<OutboundRequest>
className="mb-0"
listComponent="div"
listClassName={classNames('list-group mb-3', styles.requestsGrid)}
noun="request"
pluralNoun="requests"
queryConnection={queryOutboundRequests}
nodeComponent={MigrationNode}
filters={filters}
history={history}
location={history.location}
/>
) : (
<>
<Text>Outbound request logging is currently disabled.</Text>
<Text>
Set `outboundRequestLogLimit` to a non-zero value in your{' '}
<Link to="/site-admin/configuration">site config</Link> to enable it.
</Text>
</>
)}
</Container>
</div>
)
}
const MigrationNode: React.FunctionComponent<{ node: React.PropsWithChildren<OutboundRequest> }> = ({ node }) => {
const roundedSecond = Math.round((node.duration + Number.EPSILON) * 100) / 100
const [copied, setCopied] = useState(false)
const copyToClipboard = (text: string): void => {
copy(text)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
return (
<React.Fragment key={node.id}>
<span className={styles.separator} />
<div className="flex-bounded">
<Timestamp date={node.startedAt} noAbout={true} />
</div>
<div>
<Tooltip content="HTTP request method">
<span>
<VisuallyHidden>Request method</VisuallyHidden>
<span
className={classNames(
styles.method,
styles[node.method.toLowerCase() as keyof typeof styles]
)}
aria-hidden={true}
>
</span>{' '}
{node.method}
</span>
</Tooltip>
</div>
<div>
<Tooltip content="HTTP response status code">
<span className={isSuccessful(node) ? styles.successful : styles.failed}>
<VisuallyHidden>Status code</VisuallyHidden>
{node.statusCode}
</span>
</Tooltip>
</div>
<div className={styles.urlColumn}>{node.url}</div>
<div className={classNames('d-flex flex-row')}>
<SimplePopover label="More info">
<small className={styles.moreInfo}>
<Text>
<strong>URL: </strong>
{node.url}
</Text>
<Text>
<strong>Status: </strong>
{node.statusCode}
</Text>
<Text>
<strong>Date/time started: </strong>
<Timestamp date={node.startedAt} preferAbsolute={true} noAbout={true} />
</Text>
<Text>
<strong>Duration: </strong>
{roundedSecond.toFixed(2)} second{roundedSecond === 1 ? '' : 's'}
</Text>
<Text>
<strong>Client created at: </strong>
<Code>{node.creationStackFrame}</Code>
</Text>
<Text>
<strong>Request made at: </strong>
<Code>{node.callStackFrame}</Code>
</Text>
<Text>
<strong>Error: </strong>
{node.errorMessage ? node.errorMessage : 'No error'}
</Text>
{node.requestHeaders.length ? (
<>
<Text>
<strong>Request headers:</strong>{' '}
</Text>
<ul>
{node.requestHeaders.map(header => (
<li key={header.name}>
<strong>{header.name}</strong>: {header.values.join(', ')}
</li>
))}
</ul>
</>
) : (
'No request headers'
)}
{node.responseHeaders.length ? (
<>
<Text>
<strong>Response headers:</strong>{' '}
</Text>
<ul>
{node.responseHeaders.map(header => (
<li key={header.name}>
<strong>{header.name}</strong>: {header.values.join(', ')}
</li>
))}
</ul>
</>
) : (
'No request headers'
)}
<Text>
<strong>Request body:</strong> {node.requestBody ? node.requestBody : 'Empty body'}
</Text>
</small>
</SimplePopover>
</div>
<div>
<Tooltip content={copied ? 'Curl command copied' : 'Copy curl command (may contain REDACTED fields!)'}>
<Button className="ml-2" onClick={() => copyToClipboard(buildCurlCommand(node))}>
Copy curl
</Button>
</Tooltip>
</div>
</React.Fragment>
)
}
const SimplePopover: React.FunctionComponent<{ label: string; children: ReactNode }> = ({ label, children }) => {
const [isOpen, setIsOpen] = useState(false)
const handleOpenChange = useCallback(({ isOpen }: { isOpen: boolean }) => setIsOpen(isOpen), [setIsOpen])
return (
<Popover isOpen={isOpen} onOpenChange={handleOpenChange}>
<PopoverTrigger as={Button} variant="secondary" outline={true}>
<small>{label}</small>
<Icon aria-label="Show details" svgPath={mdiChevronDown} />
</PopoverTrigger>
<PopoverContent position={Position.bottom} focusLocked={false}>
<div className="p-2">{children}</div>
</PopoverContent>
</Popover>
)
}
function isSuccessful(request: OutboundRequest): boolean {
return request.statusCode < 400
}
function matchesString(request: OutboundRequest, query: string): boolean {
const lQuery = query.toLowerCase()
return (
request.url.toLowerCase().includes(lQuery) ||
request.method.toLowerCase().includes(lQuery) ||
request.requestBody.toLowerCase().includes(lQuery) ||
request.statusCode.toString().includes(lQuery) ||
request.errorMessage.toLowerCase().includes(lQuery) ||
request.creationStackFrame.toLowerCase().includes(lQuery) ||
request.callStackFrame.toLowerCase().includes(lQuery) ||
request.requestHeaders?.some(
header =>
header.name.toLowerCase().includes(lQuery) ||
header.values.some(value => value.toLowerCase().includes(lQuery))
) ||
request.responseHeaders?.some(
header =>
header.name.toLowerCase().includes(lQuery) ||
header.values.some(value => value.toLowerCase().includes(lQuery))
)
)
}
function buildCurlCommand(request: OutboundRequest): string {
const headers = request.requestHeaders?.map(header => `-H '${header.name}: ${header.values.join(', ')}'`).join(' ')
const body = request.requestBody ? `-d '${request.requestBody}'` : ''
return `curl -X ${request.method} ${headers} ${body} '${request.url}'`
}

View File

@ -284,6 +284,35 @@ export function fetchAllRepositoriesAndPollIfEmptyOrAnyCloning(
)
}
export const OUTBOUND_REQUESTS = gql`
query OutboundRequests($after: ID) {
outboundRequests(after: $after) {
nodes {
id
startedAt
method
url
requestHeaders {
name
values
}
requestBody
statusCode
responseHeaders {
name
values
}
duration
errorMessage
creationStackFrame
callStackFrame
}
}
}
`
export const OUTBOUND_REQUESTS_PAGE_POLL_INTERVAL = 5000
export const UPDATE_MIRROR_REPOSITORY = gql`
mutation UpdateMirrorRepository($repository: ID!) {
updateMirrorRepository(repository: $repository) {

View File

@ -81,6 +81,11 @@ export const siteAdminAreaRoutes: readonly SiteAdminAreaRoute[] = [
exact: true,
render: lazyComponent(() => import('./SiteAdminMigrationsPage'), 'SiteAdminMigrationsPage'),
},
{
path: '/outbound-requests',
exact: true,
render: lazyComponent(() => import('./SiteAdminOutboundRequestsPage'), 'SiteAdminOutboundRequestsPage'),
},
{
path: '/feature-flags',
exact: true,

View File

@ -133,6 +133,11 @@ export const maintenanceGroup: SiteAdminSideBarGroup = {
to: '/-/debug/jaeger',
source: 'server',
},
{
label: 'Outbound requests',
to: '/site-admin/outbound-requests',
source: 'server',
},
],
}

View File

@ -607,6 +607,9 @@ func newSchemaResolver(db database.DB, gitserverClient gitserver.Client) *schema
"WebhookLog": func(ctx context.Context, id graphql.ID) (Node, error) {
return webhookLogByID(ctx, db, id)
},
"OutboundRequest": func(ctx context.Context, id graphql.ID) (Node, error) {
return r.outboundRequestByID(ctx, id)
},
"Executor": func(ctx context.Context, id graphql.ID) (Node, error) {
return executorByID(ctx, db, id)
},

View File

@ -0,0 +1,145 @@
package graphqlbackend
import (
"context"
"github.com/graph-gophers/graphql-go"
"github.com/graph-gophers/graphql-go/relay"
"github.com/sourcegraph/sourcegraph/cmd/frontend/graphqlbackend/graphqlutil"
"github.com/sourcegraph/sourcegraph/internal/auth"
"github.com/sourcegraph/sourcegraph/internal/gqlutil"
"github.com/sourcegraph/sourcegraph/internal/httpcli"
"github.com/sourcegraph/sourcegraph/internal/types"
)
type outboundRequestsArgs struct {
After *graphql.ID
}
type outboundRequestResolver struct {
req *types.OutboundRequestLogItem
}
type HttpHeaders struct {
name string
values []string
}
// accessTokenConnectionResolver resolves a list of access tokens.
//
// 🚨 SECURITY: When instantiating an outboundRequestConnectionResolver value, the caller MUST check
// permissions.
type outboundRequestConnectionResolver struct {
after string
}
func (r *schemaResolver) OutboundRequests(ctx context.Context, args *outboundRequestsArgs) (*outboundRequestConnectionResolver, error) {
// 🚨 SECURITY: Only site admins may list outbound requests.
if err := auth.CheckCurrentUserIsSiteAdmin(ctx, r.db); err != nil {
return nil, err
}
var after string
if args.After != nil {
err := relay.UnmarshalSpec(*args.After, &after)
if err != nil {
return nil, err
}
} else {
after = ""
}
return &outboundRequestConnectionResolver{
after: after,
}, nil
}
func (r *schemaResolver) outboundRequestByID(ctx context.Context, id graphql.ID) (*outboundRequestResolver, error) {
// 🚨 SECURITY: Only site admins may view outbound requests.
if err := auth.CheckCurrentUserIsSiteAdmin(ctx, r.db); err != nil {
return nil, err
}
var key string
err := relay.UnmarshalSpec(id, key)
if err != nil {
return nil, err
}
item, _ := httpcli.GetOutboundRequestLogItem(key)
return &outboundRequestResolver{req: item}, nil
}
func (r *outboundRequestConnectionResolver) Nodes(ctx context.Context) ([]*outboundRequestResolver, error) {
requests, err := httpcli.GetAllOutboundRequestLogItemsAfter(ctx, r.after)
if err != nil {
return nil, err
}
resolvers := make([]*outboundRequestResolver, 0, len(requests))
for _, item := range requests {
resolvers = append(resolvers, &outboundRequestResolver{req: item})
}
return resolvers, nil
}
func (r *outboundRequestConnectionResolver) TotalCount(ctx context.Context) (int32, error) {
requests, err := httpcli.GetAllOutboundRequestLogItemsAfter(ctx, r.after)
if err != nil {
return 0, err
}
return int32(len(requests)), nil
}
func (r *outboundRequestConnectionResolver) PageInfo() (*graphqlutil.PageInfo, error) {
return graphqlutil.HasNextPage(false), nil
}
func (r *outboundRequestResolver) ID() graphql.ID {
return relay.MarshalID("OutboundRequest", r.req.ID)
}
func (r *outboundRequestResolver) StartedAt() gqlutil.DateTime {
return gqlutil.DateTime{Time: r.req.StartedAt}
}
func (r *outboundRequestResolver) Method() string { return r.req.Method }
func (r *outboundRequestResolver) URL() string { return r.req.URL }
func (r *outboundRequestResolver) RequestHeaders() ([]*HttpHeaders, error) {
return newHttpHeaders(r.req.RequestHeaders)
}
func (r *outboundRequestResolver) RequestBody() string { return r.req.RequestBody }
func (r *outboundRequestResolver) StatusCode() int32 { return r.req.StatusCode }
func (r *outboundRequestResolver) ResponseHeaders() ([]*HttpHeaders, error) {
return newHttpHeaders(r.req.ResponseHeaders)
}
func (r *outboundRequestResolver) Duration() float64 { return r.req.Duration }
func (r *outboundRequestResolver) ErrorMessage() string { return r.req.ErrorMessage }
func (r *outboundRequestResolver) CreationStackFrame() string { return r.req.CreationStackFrame }
func (r *outboundRequestResolver) CallStackFrame() string { return r.req.CallStackFrame }
func newHttpHeaders(headers map[string][]string) ([]*HttpHeaders, error) {
result := make([]*HttpHeaders, 0, len(headers))
for key, values := range headers {
result = append(result, &HttpHeaders{name: key, values: values})
}
return result, nil
}
func (h HttpHeaders) Name() string {
return h.name
}
func (h HttpHeaders) Values() []string {
return h.values
}

View File

@ -238,7 +238,7 @@ type Mutation {
createPassword(newPassword: String!): EmptyResponse
"""
Sets the user to accept the site's Terms of Service and Privacy Policy.
If the ID is ommitted, the current user is assumed.
If the ID is omitted, the current user is assumed.
Only the user or site admins may perform this mutation.
"""
@ -1575,6 +1575,16 @@ type Query {
webhookID: ID
): WebhookLogConnection!
"""
Get a log of the latest outbound external requests. Only available to site admins.
"""
outboundRequests(
"""
An opaque cursor that is used for pagination.
"""
after: ID
): OutboundRequestConnection!
"""
(experimental)
Get invitation based on the JWT in the invitation URL
@ -5069,7 +5079,7 @@ type GitBlob implements TreeEntry & File2 {
query: String
): SymbolConnection!
"""
(Experimental) Symbol defined in this blob at the specfic line number and character offset.
(Experimental) Symbol defined in this blob at the specific line number and character offset.
"""
symbol(
"""
@ -8001,7 +8011,7 @@ interface WebhookLogMessage {
"""
The headers in the HTTP message.
"""
headers: [WebhookLogHeader!]!
headers: [HTTPHeader!]!
"""
The body content of the HTTP message.
@ -8016,7 +8026,7 @@ type WebhookLogRequest implements WebhookLogMessage {
"""
The headers in the HTTP message.
"""
headers: [WebhookLogHeader!]!
headers: [HTTPHeader!]!
"""
The body content of the HTTP message.
@ -8046,7 +8056,7 @@ type WebhookLogResponse implements WebhookLogMessage {
"""
The headers in the HTTP message.
"""
headers: [WebhookLogHeader!]!
headers: [HTTPHeader!]!
"""
The body content of the HTTP message.
@ -8055,16 +8065,100 @@ type WebhookLogResponse implements WebhookLogMessage {
}
"""
A single HTTP header within a webhook log.
A list of outbound requests.
"""
type WebhookLogHeader {
type OutboundRequestConnection {
"""
The header name.
A list of webhook logs.
"""
nodes: [OutboundRequest!]!
"""
The total number of webhook logs in the connection.
"""
totalCount: Int!
"""
Pagination information.
"""
pageInfo: PageInfo!
}
"""
A single logged webhook delivery.
"""
type OutboundRequest {
"""
The request log item ID. Looks like "2021-01-01T00_00_00.000000" Same as the Redis keys.
"""
id: ID!
"""
The time the request was sent at.
"""
startedAt: DateTime!
"""
The method used in the HTTP request. E.g. GET, POST, etc.
"""
method: String!
"""
The full URL the request was sent to.
"""
url: String!
"""
The headers sent with the HTTP request.
"""
requestHeaders: [HTTPHeader!]!
"""
The body content of the HTTP message.
"""
requestBody: String!
"""
The HTTP status code received.
"""
statusCode: Int!
"""
The headers received with the HTTP response.
"""
responseHeaders: [HTTPHeader!]!
"""
The total time the request took to complete.
"""
duration: Float!
"""
Any error message got from the request Doer in case of an error, otherwise an empty string.
"""
errorMessage: String!
"""
Stack information to figure out where the ExternalClientFactory was created.
"""
creationStackFrame: String!
"""
Stack information to figure out where in the code base the request was initiated.
"""
callStackFrame: String!
}
"""
A key-value pair
"""
type HTTPHeader {
"""
The header name
"""
name: String!
"""
The header values.
Can be multiple values
"""
values: [String!]!
}

View File

@ -215,7 +215,7 @@ func mergeLeft(left, right reflect.Value, depth int) reflect.Value {
return right
}
func (r schemaResolver) ViewerSettings(ctx context.Context) (*settingsCascade, error) {
func (r *schemaResolver) ViewerSettings(ctx context.Context) (*settingsCascade, error) {
user, err := CurrentUser(ctx, r.db)
if err != nil {
return nil, err

View File

@ -271,16 +271,8 @@ type webhookLogMessageResolver struct {
message *types.WebhookLogMessage
}
func (r *webhookLogMessageResolver) Headers() []*webhookLogHeaderResolver {
headers := make([]*webhookLogHeaderResolver, 0, len(r.message.Header))
for k, v := range r.message.Header {
headers = append(headers, &webhookLogHeaderResolver{
name: k,
values: v,
})
}
return headers
func (r *webhookLogMessageResolver) Headers() ([]*HttpHeaders, error) {
return newHttpHeaders(r.message.Header)
}
func (r *webhookLogMessageResolver) Body() string {
@ -303,19 +295,6 @@ func (r *webhookLogRequestResolver) Version() string {
return r.message.Version
}
type webhookLogHeaderResolver struct {
name string
values []string
}
func (r *webhookLogHeaderResolver) Name() string {
return r.name
}
func (r *webhookLogHeaderResolver) Values() []string {
return r.values
}
func marshalWebhookID(id int32) graphql.ID {
return relay.MarshalID("Webhook", id)
}

View File

@ -116,6 +116,8 @@ type JSContext struct {
EnableLegacyExtensions bool `json:"enableLegacyExtensions"`
LicenseInfo *hooks.LicenseInfo `json:"licenseInfo"`
OutboundRequestLogLimit int `json:"outboundRequestLogLimit"`
}
// NewJSContextFromRequest populates a JSContext struct from the HTTP
@ -250,6 +252,8 @@ func NewJSContextFromRequest(req *http.Request, db database.DB) JSContext {
EnableLegacyExtensions: conf.ExperimentalFeatures().EnableLegacyExtensions,
LicenseInfo: licenseInfo,
OutboundRequestLogLimit: conf.Get().OutboundRequestLogLimit,
}
}

View File

@ -22,10 +22,16 @@ func Init() {
// package dependency cycles, since conf itself uses httpcli's internal
// client. This is gross, and the whole conf package is gross.
go Watch(func() {
before := httpcli.TLSExternalConfig()
after := Get().ExperimentalFeatures.TlsExternal
if !reflect.DeepEqual(before, after) {
httpcli.SetTLSExternalConfig(after)
tlsBefore := httpcli.TLSExternalConfig()
tlsAfter := Get().ExperimentalFeatures.TlsExternal
if !reflect.DeepEqual(tlsBefore, tlsAfter) {
httpcli.SetTLSExternalConfig(tlsAfter)
}
outboundRequestLogLimitBefore := httpcli.OutboundRequestLogLimit()
outboundRequestLogLimitAfter := Get().OutboundRequestLogLimit
if outboundRequestLogLimitBefore != outboundRequestLogLimitAfter {
httpcli.SetOutboundRequestLogLimit(outboundRequestLogLimitAfter)
}
})
}

View File

@ -78,7 +78,7 @@ type Factory struct {
common []Opt
}
// redisCache is a HTTP cache backed by Redis. The TTL of a week is a balance
// redisCache is an HTTP cache backed by Redis. The TTL of a week is a balance
// between caching values for a useful amount of time versus growing the cache
// too large.
var redisCache = rcache.NewWithTTL("http", 604800)
@ -109,6 +109,7 @@ func NewExternalClientFactory(middleware ...Middleware) *Factory {
mw := []Middleware{
ContextErrorMiddleware,
HeadersMiddleware("User-Agent", "Sourcegraph-Bot"),
redisLoggerMiddleware(),
}
mw = append(mw, middleware...)
@ -243,8 +244,8 @@ func HeadersMiddleware(headers ...string) Middleware {
}
// ContextErrorMiddleware wraps a Doer with context.Context error
// handling. It checks if the request context is done, and if so,
// returns its error. Otherwise it returns the error from the inner
// handling. It checks if the request context is done, and if so,
// returns its error. Otherwise, it returns the error from the inner
// Doer call.
func ContextErrorMiddleware(cli Doer) Doer {
return DoerFunc(func(req *http.Request) (*http.Response, error) {
@ -290,6 +291,10 @@ const (
// requestRetryAttemptKey is the key to the rehttp.Attempt attached to a request, if
// a request undergoes retries via NewRetryPolicy
requestRetryAttemptKey requestContextKey = iota
// redisLoggingMiddlewareErrorKey is the key to any errors that occurred when logging
// a request to Redis via redisLoggerMiddleware
redisLoggingMiddlewareErrorKey
)
// NewLoggingMiddleware logs basic diagnostics about requests made through this client at
@ -329,6 +334,10 @@ func NewLoggingMiddleware(logger log.Logger) Middleware {
log.Int("attempts", attempt.Index),
log.Error(attempt.Error)))
}
if redisErr, ok := ctx.Value(redisLoggingMiddlewareErrorKey).(error); ok {
// Get fields from redisLoggerMiddleware
fields = append(fields, log.NamedError("redisLoggerErr", redisErr))
}
// Log results with link to trace if present
trace.Logger(ctx, logger).
@ -356,7 +365,7 @@ func ExternalTransportOpt(cli *http.Client) error {
return nil
}
// NewCertPoolOpt returns a Opt that sets the RootCAs pool of an http.Client's
// NewCertPoolOpt returns an Opt that sets the RootCAs pool of an http.Client's
// transport.
func NewCertPoolOpt(certs ...string) Opt {
return func(cli *http.Client) error {
@ -719,3 +728,34 @@ func RequestClientTransportOpt(cli *http.Client) error {
return nil
}
// IsRiskyHeader returns true if the request or response header is likely to contain private data.
func IsRiskyHeader(name string, values []string) bool {
return isRiskyHeaderName(name) || containsRiskyHeaderValue(values)
}
// isRiskyHeaderName returns true if the request or response header is likely to contain private data
// based on its name.
func isRiskyHeaderName(name string) bool {
riskyHeaderKeys := []string{"auth", "cookie", "token"}
for _, riskyKey := range riskyHeaderKeys {
if strings.Contains(strings.ToLower(name), riskyKey) {
return true
}
}
return false
}
// ContainsRiskyHeaderValue returns true if the values array of a request or response header
// looks like it's likely to contain private data.
func containsRiskyHeaderValue(values []string) bool {
riskyHeaderValues := []string{"bearer", "ghp_", "glpat-"}
for _, value := range values {
for _, riskyValue := range riskyHeaderValues {
if strings.Contains(strings.ToLower(value), riskyValue) {
return true
}
}
}
return false
}

View File

@ -530,6 +530,68 @@ func TestLoggingMiddleware(t *testing.T) {
}
assert.NotZero(t, attemptsLogged)
})
t.Run("log redisLoggerMiddleware error", func(t *testing.T) {
const wantErrMessage = "redisLoggingError"
redisErrorMiddleware := func(next Doer) Doer {
return DoerFunc(func(req *http.Request) (*http.Response, error) {
// simplified version of what we do in redisLoggerMiddleware, since
// we just test that adding and reading the context key/value works
var middlewareErrors error
defer func() {
if middlewareErrors != nil {
*req = *req.WithContext(context.WithValue(req.Context(),
redisLoggingMiddlewareErrorKey, middlewareErrors))
}
}()
middlewareErrors = errors.New(wantErrMessage)
return next.Do(req)
})
}
logger, exportLogs := logtest.Captured(t)
cli, _ := NewFactory(
NewMiddleware(
ContextErrorMiddleware,
redisErrorMiddleware,
NewLoggingMiddleware(logger),
),
).Doer()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
t.Cleanup(srv.Close)
req, _ := http.NewRequest("GET", srv.URL, nil)
_, err := cli.Do(req)
if err != nil {
t.Fatal(err)
}
// Check log entries for logged fields about retries
logEntries := exportLogs()
assert.Greater(t, len(logEntries), 0)
var found bool
for _, entry := range logEntries {
// Check for appropriate scope
if !strings.Contains(entry.Scope, "httpcli") {
continue
}
// Check for redisLoggerErr
errField, ok := entry.Fields["redisLoggerErr"]
if !ok {
continue
}
if assert.Contains(t, errField, wantErrMessage) {
found = true
break
}
}
assert.True(t, found)
})
}
type notFoundTransport struct{}

View File

@ -7,6 +7,7 @@ import (
"net/http"
"reflect"
"sync"
"sync/atomic"
"go.opentelemetry.io/otel/attribute"
@ -26,6 +27,8 @@ var tlsExternalConfig struct {
*schema.TlsExternal
}
var outboundRequestLogLimit atomic.Int32
// SetTLSExternalConfig is called by the conf package whenever TLSExternalConfig changes.
// This is needed to avoid circular imports.
func SetTLSExternalConfig(c *schema.TlsExternal) {
@ -41,6 +44,17 @@ func TLSExternalConfig() *schema.TlsExternal {
return tlsExternalConfig.TlsExternal
}
// SetOutboundRequestLogLimit is called by the conf package whenever OutboundRequestLogLimit changes.
// This is needed to avoid circular imports.
func SetOutboundRequestLogLimit(i int) {
outboundRequestLogLimit.Store(int32(i))
}
// OutboundRequestLogLimit returns the current value of the global OutboundRequestLogLimit value.
func OutboundRequestLogLimit() int {
return int(outboundRequestLogLimit.Load())
}
func (t *externalTransport) RoundTrip(r *http.Request) (*http.Response, error) {
t.mu.RLock()
config, effective := t.config, t.effective

View File

@ -0,0 +1,216 @@
package httpcli
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"path/filepath"
"runtime"
"sort"
"strings"
"time"
"github.com/sourcegraph/sourcegraph/internal/rcache"
"github.com/sourcegraph/sourcegraph/internal/types"
"github.com/sourcegraph/sourcegraph/lib/errors"
)
var outboundRequestsRedisCache = rcache.NewWithTTL("outbound-requests", 604800)
func redisLoggerMiddleware() Middleware {
creatorStackFrame, _ := getFrames(4).Next()
return func(cli Doer) Doer {
return DoerFunc(func(req *http.Request) (*http.Response, error) {
start := time.Now()
resp, err := cli.Do(req)
duration := time.Since(start)
limit := OutboundRequestLogLimit()
// Feature is turned off, do not log
if limit == 0 {
return resp, err
}
// middlewareErrors will be set later if there is an error
var middlewareErrors error
defer func() {
if middlewareErrors != nil {
*req = *req.WithContext(context.WithValue(req.Context(),
redisLoggingMiddlewareErrorKey, middlewareErrors))
}
}()
// Read body
var requestBody []byte
if req != nil {
body, _ := req.GetBody()
if body != nil {
var readErr error
requestBody, readErr = io.ReadAll(body)
if err != nil {
middlewareErrors = errors.Append(middlewareErrors,
errors.Wrap(readErr, "read body"))
}
}
}
// Create log item
var errorMessage string
if err != nil {
errorMessage = err.Error()
}
key := time.Now().UTC().Format("2006-01-02T15_04_05.999999999")
callerStackFrame, _ := getFrames(4).Next() // Caller of the caller of redisLoggerMiddleware
logItem := types.OutboundRequestLogItem{
ID: key,
StartedAt: start,
Method: req.Method,
URL: req.URL.String(),
RequestHeaders: removeSensitiveHeaders(req.Header),
RequestBody: string(requestBody),
StatusCode: int32(resp.StatusCode),
ResponseHeaders: removeSensitiveHeaders(resp.Header),
Duration: duration.Seconds(),
ErrorMessage: errorMessage,
CreationStackFrame: formatStackFrame(creatorStackFrame),
CallStackFrame: formatStackFrame(callerStackFrame),
}
// Serialize log item
logItemJson, jsonErr := json.Marshal(logItem)
if jsonErr != nil {
middlewareErrors = errors.Append(middlewareErrors,
errors.Wrap(jsonErr, "marshal log item"))
}
// Save new item
outboundRequestsRedisCache.Set(key, logItemJson)
// Delete excess items
if deleteErr := deleteExcessItems(outboundRequestsRedisCache, limit); deleteErr != nil {
middlewareErrors = errors.Append(middlewareErrors,
errors.Wrap(deleteErr, "delete excess items"))
}
return resp, err
})
}
}
func deleteExcessItems(c *rcache.Cache, limit int) error {
keys, err := c.ListKeys(context.Background())
if err != nil {
return errors.Wrap(err, "list keys")
}
// Delete all but the last N keys
if len(keys) > limit {
sort.Strings(keys)
c.DeleteMulti(keys[:len(keys)-limit]...)
}
return nil
}
// GetAllOutboundRequestLogItemsAfter returns all outbound request log items after the given key,
// in ascending order, trimmed to maximum {limit} items. Example for `after`: "2021-01-01T00_00_00.000000".
func GetAllOutboundRequestLogItemsAfter(ctx context.Context, after string) ([]*types.OutboundRequestLogItem, error) {
var limit = OutboundRequestLogLimit()
if limit == 0 {
return []*types.OutboundRequestLogItem{}, nil
}
rawItems, err := getAllValuesAfter(ctx, outboundRequestsRedisCache, after, limit)
if err != nil {
return nil, err
}
items := make([]*types.OutboundRequestLogItem, 0, len(rawItems))
for _, rawItem := range rawItems {
var item types.OutboundRequestLogItem
err = json.Unmarshal(rawItem, &item)
if err != nil {
return nil, err
}
items = append(items, &item)
}
return items, nil
}
func GetOutboundRequestLogItem(key string) (*types.OutboundRequestLogItem, error) {
rawItem, ok := outboundRequestsRedisCache.Get(key)
if !ok {
return nil, errors.New("item not found")
}
var item types.OutboundRequestLogItem
err := json.Unmarshal(rawItem, &item)
if err != nil {
return nil, err
}
return &item, nil
}
// getAllValuesAfter returns all items after the given key, in ascending order, trimmed to maximum {limit} items.
func getAllValuesAfter(ctx context.Context, c *rcache.Cache, after string, limit int) ([][]byte, error) {
all, err := c.ListKeys(ctx)
if err != nil {
return nil, err
}
var keys []string
if after != "" {
for _, key := range all {
if key > after {
keys = append(keys, key)
}
}
} else {
keys = all
}
// Sort ascending
sort.Strings(keys)
// Limit to N
if len(keys) > limit {
keys = keys[len(keys)-limit:]
}
return c.GetMulti(keys...), nil
}
func removeSensitiveHeaders(headers http.Header) http.Header {
var cleanHeaders = make(http.Header)
for name, values := range headers {
if IsRiskyHeader(name, values) {
cleanHeaders[name] = []string{"REDACTED"}
} else {
cleanHeaders[name] = values
}
}
return cleanHeaders
}
func formatStackFrame(frame runtime.Frame) string {
packageTreeAndFunctionName := strings.Join(strings.Split(frame.Function, "/")[3:], "/")
dotPieces := strings.Split(packageTreeAndFunctionName, ".")
packageTree := dotPieces[0]
functionName := dotPieces[len(dotPieces)-1]
// Reconstruct the frame file path so that we don't include the local path on the machine that built this instance
fileName := filepath.Join(packageTree, filepath.Base(frame.File))
return fmt.Sprintf("%s:%d (Function: %s)", fileName, frame.Line, functionName)
}
const pcLen = 1024
func getFrames(skip int) *runtime.Frames {
pc := make([]uintptr, pcLen)
n := runtime.Callers(skip, pc)
return runtime.CallersFrames(pc[:n])
}

View File

@ -0,0 +1,95 @@
package httpcli
import (
"context"
"net/http"
"strconv"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/sourcegraph/sourcegraph/internal/rcache"
"github.com/stretchr/testify/assert"
"k8s.io/utils/strings/slices"
)
func TestRedisLoggerMiddleware_getAllValuesAfter(t *testing.T) {
rcache.SetupForTest(t)
c := rcache.NewWithTTL("some_prefix", 1)
ctx := context.Background()
var pairs = make([][2]string, 10)
for i := 0; i < 10; i++ {
pairs[i] = [2]string{"key" + strconv.Itoa(i), "value" + strconv.Itoa(i)}
}
c.SetMulti(pairs...)
key := "key5"
got, err := getAllValuesAfter(ctx, c, key, 10)
assert.Nil(t, err)
assert.Len(t, got, 4)
got, err = getAllValuesAfter(ctx, c, key, 2)
assert.Nil(t, err)
assert.Len(t, got, 2)
}
func TestRedisLoggerMiddleware_removeSensitiveHeaders(t *testing.T) {
input := http.Header{
"Authorization": []string{"all values", "should be", "removed"},
"Bearer": []string{"this should be kept as the risky value is only in the name"},
"GHP_XXXX": []string{"this should be kept"},
"GLPAT-XXXX": []string{"this should also be kept"},
"GitHub-PAT": []string{"this should be removed: ghp_XXXX"},
"GitLab-PAT": []string{"this should be removed", "glpat-XXXX"},
"Innocent-Header": []string{"this should be removed as it includes", "the word bearer"},
"Set-Cookie": []string{"this is verboten"},
"Token": []string{"a token should be removed"},
"X-Powered-By": []string{"PHP"},
"X-Token": []string{"something that smells like a token should also be removed"},
}
// Build the expected output.
want := make(http.Header)
riskyKeys := []string{"Bearer", "GHP_XXXX", "GLPAT-XXXX", "X-Powered-By"}
for key, value := range input {
if slices.Contains(riskyKeys, key) {
want[key] = value
} else {
want[key] = []string{"REDACTED"}
}
}
cleanHeaders := removeSensitiveHeaders(input)
if diff := cmp.Diff(cleanHeaders, want); diff != "" {
t.Errorf("unexpected request headers (-have +want):\n%s", diff)
}
}
func TestCache_DeleteFirstN(t *testing.T) {
rcache.SetupForTest(t)
c := rcache.NewWithTTL("some_prefix", 1)
// Add 10 key-value pairs
var pairs = make([][2]string, 10)
for i := 0; i < 10; i++ {
pairs[i] = [2]string{"key" + strconv.Itoa(i), "value" + strconv.Itoa(i)}
}
c.SetMulti(pairs...)
// Delete the first 4 key-value pairs
deleteExcessItems(c, 4)
got, listErr := c.ListKeys(context.Background())
assert.Nil(t, listErr)
assert.Len(t, got, 4)
assert.NotContains(t, got, "key0") // 0 through 5 should be deleted
assert.NotContains(t, got, "key5")
assert.Contains(t, got, "key6") // 6 through 9 (4 items) should be kept
assert.Contains(t, got, "key9")
}

View File

@ -110,38 +110,10 @@ func NewRecorderFactory(t testing.TB, update bool, name string) (*httpcli.Factor
// riskyHeaderFilter deletes anything that looks risky in request and response
// headers.
func riskyHeaderFilter(i *cassette.Interaction) error {
riskyHeaderKeys := []string{
"auth", "cookie", "token",
}
riskyHeaderValues := []string{
"bearer", "ghp_", "glpat-",
}
isRiskyKey := func(key string) bool {
lowerKey := strings.ToLower(key)
for _, riskyKey := range riskyHeaderKeys {
if strings.Contains(lowerKey, riskyKey) {
return true
}
}
return false
}
hasRiskyValue := func(values []string) bool {
for _, value := range values {
lowerValue := strings.ToLower(value)
for _, riskyValue := range riskyHeaderValues {
if strings.Contains(lowerValue, riskyValue) {
return true
}
}
}
return false
}
for _, headers := range []http.Header{i.Request.Headers, i.Response.Headers} {
for k, values := range headers {
if isRiskyKey(k) || hasRiskyValue(values) {
delete(headers, k)
for name, values := range headers {
if httpcli.IsRiskyHeader(name, values) {
delete(headers, name)
}
}
}

View File

@ -4,7 +4,6 @@ import (
"context"
"fmt"
"os"
"strings"
"time"
"unicode/utf8"
@ -26,7 +25,7 @@ const dataVersionToDelete = "v1"
// DeleteOldCacheData deletes the rcache data in the given Redis instance
// that's prefixed with dataVersionToDelete
func DeleteOldCacheData(c redis.Conn) error {
return deleteKeysWithPrefix(c, dataVersionToDelete)
return deleteAllKeysWithPrefix(c, dataVersionToDelete)
}
// Cache implements httpcache.Cache
@ -172,10 +171,19 @@ func (r *Cache) Increase(key string) {
}
}
// DeleteMulti deletes the given keys.
func (r *Cache) DeleteMulti(keys ...string) {
for _, key := range keys {
r.Delete(key)
}
}
// Delete implements httpcache.Cache.Delete
func (r *Cache) Delete(key string) {
c := pool.Get()
defer c.Close()
defer func(c redis.Conn) {
_ = c.Close()
}(c)
_, err := c.Do("DEL", r.rkeyPrefix()+key)
if err != nil {
@ -183,21 +191,25 @@ func (r *Cache) Delete(key string) {
}
}
// ListKeys lists all keys associated with this cache. Use with care if you have long
// TTLs or no TTL configured.
func (r *Cache) ListKeys(ctx context.Context) ([]string, error) {
c, err := pool.GetContext(ctx)
// ListKeys lists all keys associated with this cache.
// Use with care if you have long TTLs or no TTL configured.
func (r *Cache) ListKeys(ctx context.Context) (results []string, err error) {
var c redis.Conn
c, err = pool.GetContext(ctx)
if err != nil {
return nil, errors.Wrap(err, "get redis conn")
}
defer c.Close()
defer func(c redis.Conn) {
if tempErr := c.Close(); err == nil {
err = tempErr
}
}(c)
var allKeys []string
cursor := 0
for {
select {
case <-ctx.Done():
return allKeys, ctx.Err()
return results, ctx.Err()
default:
}
@ -207,22 +219,22 @@ func (r *Cache) ListKeys(ctx context.Context) ([]string, error) {
"COUNT", 100),
)
if err != nil {
return allKeys, errors.Wrap(err, "redis scan")
return results, errors.Wrap(err, "redis scan")
}
cursor, _ = redis.Int(res[0], nil)
keys, _ := redis.Strings(res[1], nil)
for i, k := range keys {
keys[i] = strings.TrimPrefix(k, r.rkeyPrefix())
keys[i] = k[len(r.rkeyPrefix()):]
}
allKeys = append(allKeys, keys...)
results = append(results, keys...)
if cursor == 0 {
break
}
}
return allKeys, nil
return
}
// rkeyPrefix generates the actual key prefix we use on redis.
@ -266,7 +278,7 @@ func SetupForTest(t TB) {
}
}
err := deleteKeysWithPrefix(c, globalPrefix)
err := deleteAllKeysWithPrefix(c, globalPrefix)
if err != nil {
log15.Error("Could not clear test prefix", "name", t.Name(), "globalPrefix", globalPrefix, "error", err)
}
@ -279,7 +291,7 @@ func SetupForTest(t TB) {
// See https://www.lua.org/source/5.1/luaconf.h.html
var deleteBatchSize = 5000
func deleteKeysWithPrefix(c redis.Conn, prefix string) error {
func deleteAllKeysWithPrefix(c redis.Conn, prefix string) error {
const script = `
redis.replicate_commands()
local cursor = '0'

View File

@ -140,9 +140,14 @@ func TestCache_multi(t *testing.T) {
if got, exp := c.GetMulti("k0", "k1", "k2"), [][]byte{nil, []byte("y"), []byte("z")}; !reflect.DeepEqual(exp, got) {
t.Errorf("Expected %v, but got %v", exp, got)
}
c.DeleteMulti("k1", "k2")
if got, exp := c.GetMulti("k0", "k1", "k2"), [][]byte{nil, nil, nil}; !reflect.DeepEqual(exp, got) {
t.Errorf("Expected %v, but got %v", exp, got)
}
}
func TestCache_deleteKeysWithPrefix(t *testing.T) {
func TestCache_deleteAllKeysWithPrefix(t *testing.T) {
SetupForTest(t)
// decrease the deleteBatchSize
@ -168,7 +173,7 @@ func TestCache_deleteKeysWithPrefix(t *testing.T) {
conn := pool.Get()
defer conn.Close()
err := deleteKeysWithPrefix(conn, c.rkeyPrefix()+"a")
err := deleteAllKeysWithPrefix(conn, c.rkeyPrefix()+"a")
if err != nil {
t.Error(err)
}
@ -187,7 +192,7 @@ func TestCache_deleteKeysWithPrefix(t *testing.T) {
func TestCache_Increase(t *testing.T) {
SetupForTest(t)
c := NewWithTTL("some_prefix:", 1)
c := NewWithTTL("some_prefix", 1)
c.Increase("a")
got, ok := c.Get("a")
@ -206,7 +211,7 @@ func TestCache_Increase(t *testing.T) {
func TestCache_ListKeys(t *testing.T) {
SetupForTest(t)
c := NewWithTTL("some_prefix:", 1)
c := NewWithTTL("some_prefix", 1)
c.SetMulti(
[2]string{"foobar", "123"},
[2]string{"bazbar", "456"},

View File

@ -1709,3 +1709,18 @@ type Webhook struct {
CreatedByUserID int32
UpdatedByUserID int32
}
type OutboundRequestLogItem struct {
ID string `json:"id"`
StartedAt time.Time `json:"startedAt"`
Method string `json:"method"` // The request method (GET, POST, etc.)
URL string `json:"url"`
RequestHeaders map[string][]string `json:"requestHeaders"`
RequestBody string `json:"requestBody"`
StatusCode int32 `json:"statusCode"` // The response status code
ResponseHeaders map[string][]string `json:"responseHeaders"`
Duration float64 `json:"duration"`
ErrorMessage string `json:"errorMessage"`
CreationStackFrame string `json:"creationStackFrame"`
CallStackFrame string `json:"callStackFrame"`
}

View File

@ -2210,6 +2210,8 @@ type SiteConfiguration struct {
ObservabilityTracing *ObservabilityTracing `json:"observability.tracing,omitempty"`
// OrganizationInvitations description: Configuration for organization invitations.
OrganizationInvitations *OrganizationInvitations `json:"organizationInvitations,omitempty"`
// OutboundRequestLogLimit description: The maximum number of outbound requests to retain. This is a global limit across all outbound requests. If the limit is exceeded, older items will be deleted. If the limit is 0, no outbound requests are logged.
OutboundRequestLogLimit int `json:"outboundRequestLogLimit,omitempty"`
// ParentSourcegraph description: URL to fetch unreachable repository details from. Defaults to "https://sourcegraph.com"
ParentSourcegraph *ParentSourcegraph `json:"parentSourcegraph,omitempty"`
// PermissionsSyncOldestRepos description: Number of repo permissions to schedule for syncing in single scheduler iteration.

View File

@ -1885,6 +1885,13 @@
}
}
},
"outboundRequestLogLimit": {
"description": "The maximum number of outbound requests to retain. This is a global limit across all outbound requests. If the limit is exceeded, older items will be deleted. If the limit is 0, no outbound requests are logged.",
"type": "integer",
"minimum": 0,
"default": 50,
"maximum": 500
},
"organizationInvitations": {
"description": "Configuration for organization invitations.",
"type": "object",