mirror of
https://github.com/sourcegraph/sourcegraph.git
synced 2026-02-06 18:31:54 +00:00
Revert "Revert "Log external requests (#44286)" (#44890)" aka reapply Log External Requests (#44893)
Revert "Revert "Log external requests (#44286)" (#44890)"
This reverts commit 8dc8fb0ad1.
Co-authored-by: David Veszelovszki <veszelovszki@gmail.com>
This commit is contained in:
parent
c6e100582a
commit
ca0d18a290
@ -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
|
||||
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
354
client/web/src/site-admin/SiteAdminOutboundRequestsPage.tsx
Normal file
354
client/web/src/site-admin/SiteAdminOutboundRequestsPage.tsx
Normal 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}'`
|
||||
}
|
||||
@ -287,6 +287,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) {
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -133,6 +133,11 @@ export const maintenanceGroup: SiteAdminSideBarGroup = {
|
||||
to: '/-/debug/jaeger',
|
||||
source: 'server',
|
||||
},
|
||||
{
|
||||
label: 'Outbound requests',
|
||||
to: '/site-admin/outbound-requests',
|
||||
source: 'server',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
},
|
||||
|
||||
145
cmd/frontend/graphqlbackend/outbound_requests.go
Normal file
145
cmd/frontend/graphqlbackend/outbound_requests.go
Normal 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
|
||||
}
|
||||
@ -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!]!
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
@ -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{}
|
||||
|
||||
@ -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
|
||||
|
||||
216
internal/httpcli/redis_logger_middleware.go
Normal file
216
internal/httpcli/redis_logger_middleware.go
Normal 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])
|
||||
}
|
||||
95
internal/httpcli/redis_logger_middleware_test.go
Normal file
95
internal/httpcli/redis_logger_middleware_test.go
Normal 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")
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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'
|
||||
|
||||
@ -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"},
|
||||
|
||||
@ -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"`
|
||||
}
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user