[next] Error and Logging Improvements (#2152)

* feat: added locale support for Tenant

* feat: added secret scrubbing to logs

* chore: cleanup logger

* chore: logger improvements

* feat: re-introduce scoped pretty logger

* feat: added initial error support

* refactor: replace trace-error.TraceError with talk.InternalError

* fix: fixed error logging

* refactor: replaced Error with VError

* fix: repaired issue with error management on api

* fix: patched bug with not found handler

* feat: added translations

* feat: added location path to invalid entries

* refactor: refactored error handling on graph

* fix: moved indexing operations to master node

* refactor: added throw for when the message isn't found in testing

* fix: removed duplicate log

* fix: fixed naming on environment variable
This commit is contained in:
Wyatt Johnson
2019-02-06 23:42:17 +00:00
committed by GitHub
parent 9b0e6ed53b
commit 9fa5900acc
63 changed files with 1780 additions and 373 deletions
+16 -2
View File
@@ -1,29 +1,43 @@
import uuid from "uuid";
import { LanguageCode } from "talk-common/helpers/i18n/locales";
import { Config } from "talk-server/config";
import logger from "talk-server/logger";
import { User } from "talk-server/models/user";
import { I18n } from "talk-server/services/i18n";
import { Request } from "talk-server/types/express";
export interface CommonContextOptions {
user?: User;
req?: Request;
lang?: LanguageCode;
config: Config;
i18n: I18n;
}
export default class CommonContext {
public readonly user?: User;
public readonly req?: Request;
public readonly config: Config;
public readonly i18n: I18n;
public readonly lang: LanguageCode;
public readonly logger = logger.child({
context: "graph",
contextID: uuid.v4(),
contextID: uuid.v1(),
});
constructor({ user, req, config }: CommonContextOptions) {
constructor({
user,
req,
config,
i18n,
lang = i18n.getDefaultLang(),
}: CommonContextOptions) {
this.user = user;
this.req = req;
this.config = config;
this.i18n = i18n;
this.lang = lang;
}
}
@@ -1,6 +1,8 @@
import { DirectiveResolverFn } from "graphql-tools";
import { memoize } from "lodash";
import { GraphQLResolveInfo, ResponsePath } from "graphql";
import { UserForbiddenError } from "talk-server/errors";
import CommonContext from "talk-server/graph/common/context";
import {
GQLUSER_AUTH_CONDITIONS,
@@ -31,6 +33,38 @@ function calculateAuthConditions(user: User): GQLUSER_AUTH_CONDITIONS[] {
return conditions.sort();
}
/**
* calculateLocationKey will reduce the resolve information to determine the
* path to where the key that is being accessed.
*
* @param info the info from the graph request
*/
function calculateLocationKey(info: Pick<GraphQLResolveInfo, "path">): string {
// Guard against invalid input.
if (!info || !info.path || !info.path.key) {
return "";
}
// Grab the first part of the path.
const parts: string[] = [info.path.key.toString()];
// Grab the parent previous part of the path.
let prev: ResponsePath | undefined = info.path.prev;
// While there is still a previous part of the path, keep looping to find the
// all the parts.
while (prev && prev.key) {
// Push the key into the front of the array.
parts.unshift(prev.key.toString());
// Change the selection to the previous path element.
prev = prev.prev;
}
// Join it together with a dotted path.
return parts.join(".");
}
const calculateAuthConditionsMemoized = memoize(calculateAuthConditions);
const auth: DirectiveResolverFn<
@@ -40,7 +74,8 @@ const auth: DirectiveResolverFn<
next,
src,
{ roles, userIDField, permit }: AuthDirectiveArgs,
{ user }
{ user },
info
) => {
// If there is a user on the request.
if (user) {
@@ -48,8 +83,11 @@ const auth: DirectiveResolverFn<
// User, if they do error.
const conditions = calculateAuthConditionsMemoized(user);
if (!permit && conditions.length > 0) {
// TODO: return better error.
throw new Error("not authorized 1");
throw new UserForbiddenError(
"authentication conditions not met",
calculateLocationKey(info),
user.id
);
}
// If the permit was specified, and some of the conditions for the user
@@ -58,8 +96,11 @@ const auth: DirectiveResolverFn<
permit &&
conditions.some(condition => permit.indexOf(condition) === -1)
) {
// TODO: return better error.
throw new Error("not authorized 2");
throw new UserForbiddenError(
"authentication conditions not met",
calculateLocationKey(info),
user.id
);
}
// If the role and user owner checks are disabled, then allow them based on
@@ -80,8 +121,11 @@ const auth: DirectiveResolverFn<
}
}
// TODO: return better error.
throw new Error("not authorized");
throw new UserForbiddenError(
"user does not have permission to access the resource",
calculateLocationKey(info),
user ? user.id : null
);
};
export default auth;
+36
View File
@@ -0,0 +1,36 @@
import { ERROR_CODES } from "talk-common/errors";
import { TalkError } from "talk-server/errors";
/**
* mapFieldsetToErrorCodes will wait for any errors to occur with the request,
* and then associate the appropriate field that caused the error to the error
* itself so it can link context in the UI.
*
* @param promise the promise to await on for any errors to occur
* @param errorMap the map of error codes to associate with a given fieldSet
*/
export async function mapFieldsetToErrorCodes<T>(
promise: Promise<T>,
errorMap: Record<string, ERROR_CODES[]>
): Promise<T> {
try {
return await promise;
} catch (err) {
// If the error is a TalkError...
if (err instanceof TalkError) {
// Then loop over all the fieldSpecs...
for (const param in errorMap) {
if (!errorMap.hasOwnProperty(param)) {
continue;
}
if (errorMap[param].some(code => err.code === code)) {
err.param = param;
break;
}
}
}
throw err;
}
}
@@ -0,0 +1,79 @@
import { GraphQLError } from "graphql";
import { GraphQLExtension, GraphQLResponse } from "graphql-extensions";
import { merge } from "lodash";
import { InternalError, TalkError } from "talk-server/errors";
import CommonContext from "talk-server/graph/common/context";
function hoistTalkErrorExtensions(ctx: CommonContext, err: GraphQLError): void {
if (!err.originalError) {
// Only errors that have an originalError need to be hoisted.
return;
}
// Grab or wrap the originalError so that it's a TalkError.
const originalError: TalkError =
err.originalError instanceof TalkError
? err.originalError
: new InternalError(err.originalError, "wrapped internal error");
// Get the translation bundle.
const bundle = ctx.i18n.getBundle(ctx.lang);
// Translate the extensions.
const extensions = originalError.serializeExtensions(bundle);
// Hoist the message from the original error into the message of the base
// error.
err.message = extensions.message;
// Re-hoist the extensions.
merge(err.extensions, extensions);
return;
}
/**
* enrichAndLogError will enrich and then log out the error.
*
* @param ctx the GraphQL context for the request
* @param err the error that occurred
*/
export function enrichError(
ctx: CommonContext,
err: GraphQLError
): GraphQLError {
if (err.extensions) {
// Delete the exception field from the error extension, we never need to
// provide that data.
if (err.extensions.exception) {
delete err.extensions.exception;
}
if (err.originalError) {
// Hoist the extensions onto the error.
hoistTalkErrorExtensions(ctx, err);
}
}
return err;
}
export class ErrorWrappingExtension implements GraphQLExtension<CommonContext> {
public willSendResponse(o: {
graphqlResponse: GraphQLResponse;
context: CommonContext;
}): void | { graphqlResponse: GraphQLResponse; context: CommonContext } {
if (o.graphqlResponse.errors) {
return {
...o,
graphqlResponse: {
...o.graphqlResponse,
errors: o.graphqlResponse.errors.map(err =>
enrichError(o.context, err)
),
},
};
}
}
}
@@ -1,4 +1,3 @@
import { formatApolloErrors } from "apollo-server-errors";
import {
DocumentNode,
ExecutionArgs,
@@ -14,17 +13,11 @@ import now from "performance-now";
import CommonContext from "talk-server/graph/common/context";
export function logError(ctx: CommonContext, err: GraphQLError) {
ctx.logger.error({ err }, "graphql query error");
}
export class LoggerExtension implements GraphQLExtension<CommonContext> {
private logError = (ctx: CommonContext) => (err: Error) => {
if (err instanceof GraphQLError) {
ctx.logger.error({ err: err.originalError }, "graphql error");
} else {
ctx.logger.error({ err }, "graphql query error");
}
return err;
};
private getOperationMetadata(doc: DocumentNode) {
if (doc.kind === "Document") {
const operationDefinition = doc.definitions.find(
@@ -70,21 +63,14 @@ export class LoggerExtension implements GraphQLExtension<CommonContext> {
}
}
public willSendResponse(o: {
public willSendResponse(response: {
graphqlResponse: GraphQLResponse;
context: CommonContext;
}): void | { graphqlResponse: GraphQLResponse; context: CommonContext } {
if (o.graphqlResponse.errors) {
return {
...o,
graphqlResponse: {
...o.graphqlResponse,
errors: formatApolloErrors(o.graphqlResponse.errors, {
formatter: this.logError(o.context),
debug: false,
}),
},
};
}): void {
if (response.graphqlResponse.errors) {
response.graphqlResponse.errors.forEach(err =>
logError(response.context, err)
);
}
}
}
@@ -10,7 +10,9 @@ import {
import { Omit } from "talk-common/types";
import { Config } from "talk-server/config";
import { LoggerExtension } from "talk-server/graph/common/middleware/extensions/logger";
import { ErrorWrappingExtension } from "./extensions/ErrorWrappingExtension";
import { LoggerExtension } from "./extensions/LoggerExtension";
// Sourced from: https://github.com/apollographql/apollo-server/blob/958846887598491fadea57b3f9373d129300f250/packages/apollo-server-core/src/ApolloServer.ts#L46-L57
const NoIntrospection = (context: ValidationContext) => ({
@@ -36,7 +38,7 @@ export const graphqlMiddleware = (
debug: false,
// Include extensions.
extensions: [
// Log queries and errors.
() => new ErrorWrappingExtension(),
() => new LoggerExtension(),
],
};
@@ -2,10 +2,10 @@ import { RedisPubSub } from "graphql-redis-subscriptions";
import { Config } from "talk-server/config";
import { createRedisClient } from "talk-server/services/redis";
export function createPubSub(config: Config): RedisPubSub {
export async function createPubSub(config: Config): Promise<RedisPubSub> {
// Create the Redis clients for the PubSub server.
const publisher = createRedisClient(config);
const subscriber = createRedisClient(config);
const publisher = await createRedisClient(config);
const subscriber = await createRedisClient(config);
// Create the new PubSub manager.
return new RedisPubSub({
+4 -2
View File
@@ -2,19 +2,21 @@ import { Db } from "mongodb";
import { Config } from "talk-server/config";
import CommonContext from "talk-server/graph/common/context";
import { I18n } from "talk-server/services/i18n";
import { Request } from "talk-server/types/express";
export interface ManagementContextOptions {
mongo: Db;
config: Config;
i18n: I18n;
req?: Request;
}
export default class ManagementContext extends CommonContext {
public readonly mongo: Db;
constructor({ req, mongo, config }: ManagementContextOptions) {
super({ req, config });
constructor({ req, mongo, config, i18n }: ManagementContextOptions) {
super({ req, config, i18n });
this.mongo = mongo;
}
+15 -2
View File
@@ -5,10 +5,23 @@ import { Config } from "talk-server/config";
import { graphqlMiddleware } from "talk-server/graph/common/middleware";
import { Request } from "talk-server/types/express";
import { I18n } from "talk-server/services/i18n";
import ManagementContext from "./context";
export default (schema: GraphQLSchema, config: Config, mongo: Db) =>
export interface ManagementGraphQLMiddlewareOptions {
schema: GraphQLSchema;
config: Config;
mongo: Db;
i18n: I18n;
}
export default ({
schema,
config,
mongo,
i18n,
}: ManagementGraphQLMiddlewareOptions) =>
graphqlMiddleware(config, async (req: Request) => ({
schema,
context: new ManagementContext({ req, mongo, config }),
context: new ManagementContext({ req, mongo, config, i18n }),
}));
+4 -1
View File
@@ -10,6 +10,7 @@ import { AugmentedRedis } from "talk-server/services/redis";
import TenantCache from "talk-server/services/tenant/cache";
import { Request } from "talk-server/types/express";
import { I18n } from "talk-server/services/i18n";
import loaders from "./loaders";
import mutators from "./mutators";
@@ -23,6 +24,7 @@ export interface TenantContextOptions {
signingConfig?: JWTSigningConfig;
req?: Request;
user?: User;
i18n: I18n;
}
export default class TenantContext extends CommonContext {
@@ -46,8 +48,9 @@ export default class TenantContext extends CommonContext {
tenantCache,
queue,
signingConfig,
i18n,
}: TenantContextOptions) {
super({ user, req, config });
super({ user, req, config, i18n, lang: tenant.locale });
this.tenant = tenant;
this.tenantCache = tenantCache;
@@ -1,7 +1,7 @@
import { isNull, omitBy } from "lodash";
import TenantContext from "talk-server/graph/tenant/context";
import { GQLSettingsInput } from "talk-server/graph/tenant/schema/__generated__/types";
import { GQLUpdateSettingsInput } from "talk-server/graph/tenant/schema/__generated__/types";
import { Tenant } from "talk-server/models/tenant";
import { regenerateSSOKey, update } from "talk-server/services/tenant";
@@ -11,8 +11,8 @@ export const Settings = ({
tenantCache,
tenant,
}: TenantContext) => ({
update: (input: GQLSettingsInput): Promise<Tenant | null> =>
update(mongo, redis, tenantCache, tenant, omitBy(input, isNull)),
update: (input: GQLUpdateSettingsInput): Promise<Tenant | null> =>
update(mongo, redis, tenantCache, tenant, omitBy(input.settings, isNull)),
regenerateSSOKey: (): Promise<Tenant | null> =>
regenerateSSOKey(mongo, redis, tenantCache, tenant),
});
+25 -7
View File
@@ -1,5 +1,7 @@
import { isNull, omitBy } from "lodash";
import { ERROR_CODES } from "talk-common/errors";
import { mapFieldsetToErrorCodes } from "talk-server/graph/common/errors";
import TenantContext from "talk-server/graph/tenant/context";
import {
GQLCreateStoryInput,
@@ -16,17 +18,33 @@ export const Story = (ctx: TenantContext) => ({
create: async (
input: GQLCreateStoryInput
): Promise<Readonly<story.Story> | null> =>
create(
ctx.mongo,
ctx.tenant,
input.story.id,
input.story.url,
omitBy(input.story, isNull)
mapFieldsetToErrorCodes(
create(
ctx.mongo,
ctx.tenant,
input.story.id,
input.story.url,
omitBy(input.story, isNull)
),
{
"input.story.url": [
ERROR_CODES.STORY_URL_NOT_PERMITTED,
ERROR_CODES.DUPLICATE_STORY_URL,
],
}
),
update: async (
input: GQLUpdateStoryInput
): Promise<Readonly<story.Story> | null> =>
update(ctx.mongo, ctx.tenant, input.id, omitBy(input.story, isNull)),
mapFieldsetToErrorCodes(
update(ctx.mongo, ctx.tenant, input.id, omitBy(input.story, isNull)),
{
"input.story.url": [
ERROR_CODES.STORY_URL_NOT_PERMITTED,
ERROR_CODES.DUPLICATE_STORY_URL,
],
}
),
merge: async (
input: GQLMergeStoriesInput
): Promise<Readonly<story.Story> | null> =>
+26 -2
View File
@@ -1,3 +1,5 @@
import { ERROR_CODES } from "talk-common/errors";
import { mapFieldsetToErrorCodes } from "talk-server/graph/common/errors";
import TenantContext from "talk-server/graph/tenant/context";
import * as user from "talk-server/models/user";
import {
@@ -13,6 +15,7 @@ import {
updateRole,
updateUsername,
} from "talk-server/services/users";
import {
GQLCreateTokenInput,
GQLDeactivateTokenInput,
@@ -31,11 +34,32 @@ export const User = (ctx: TenantContext) => ({
setUsername: async (
input: GQLSetUsernameInput
): Promise<Readonly<user.User> | null> =>
setUsername(ctx.mongo, ctx.tenant, ctx.user!, input.username),
mapFieldsetToErrorCodes(
setUsername(ctx.mongo, ctx.tenant, ctx.user!, input.username),
{
"input.username": [
ERROR_CODES.USERNAME_ALREADY_SET,
ERROR_CODES.USERNAME_CONTAINS_INVALID_CHARACTERS,
ERROR_CODES.USERNAME_EXCEEDS_MAX_LENGTH,
ERROR_CODES.USERNAME_TOO_SHORT,
ERROR_CODES.DUPLICATE_USERNAME,
],
}
),
setEmail: async (
input: GQLSetEmailInput
): Promise<Readonly<user.User> | null> =>
setEmail(ctx.mongo, ctx.tenant, ctx.user!, input.email),
mapFieldsetToErrorCodes(
setEmail(ctx.mongo, ctx.tenant, ctx.user!, input.email),
{
"input.email": [
ERROR_CODES.EMAIL_ALREADY_SET,
ERROR_CODES.DUPLICATE_EMAIL,
ERROR_CODES.EMAIL_INVALID_FORMAT,
ERROR_CODES.EMAIL_EXCEEDS_MAX_LENGTH,
],
}
),
setPassword: async (
input: GQLSetPasswordInput
): Promise<Readonly<user.User> | null> =>
@@ -0,0 +1,28 @@
import { LanguageCode } from "talk-common/helpers/i18n/locales";
import { GQLLOCALES } from "../schema/__generated__/types";
import { LOCALES } from "./LOCALES";
it("does not contain duplicate entries", () => {
const seen: Partial<Record<LanguageCode, true>> = {};
for (const key in LOCALES) {
if (!LOCALES.hasOwnProperty(key)) {
continue;
}
const value = LOCALES[key as GQLLOCALES];
expect(value in seen).toBeFalsy();
seen[value] = true;
}
});
it("contains the correct mappings to the BCP 47 format", () => {
for (const key in LOCALES) {
if (!LOCALES.hasOwnProperty(key)) {
continue;
}
const value = LOCALES[key as GQLLOCALES];
expect(value).toEqual(key.replace(/_/, "-"));
}
});
@@ -0,0 +1,9 @@
import { LanguageCode } from "talk-common/helpers/i18n/locales";
import { GQLLOCALES } from "../schema/__generated__/types";
export const LOCALES: Record<GQLLOCALES, LanguageCode> = {
en_US: "en-US",
es: "es",
de: "de",
};
@@ -26,7 +26,7 @@ export const Mutation: Required<GQLMutationTypeResolver<void>> = {
clientMutationId: input.clientMutationId,
}),
updateSettings: async (source, { input }, ctx) => ({
settings: await ctx.mutators.Settings.update(input.settings),
settings: await ctx.mutators.Settings.update(input),
clientMutationId: input.clientMutationId,
}),
createCommentReaction: async (source, { input }, ctx) => ({
+17 -1
View File
@@ -1,8 +1,14 @@
import { attachDirectiveResolvers, IResolvers } from "graphql-tools";
import {
addResolveFunctionsToSchema,
attachDirectiveResolvers,
IEnumResolver,
IResolvers,
} from "graphql-tools";
import { loadSchema } from "talk-common/graphql";
import auth from "talk-server/graph/common/directives/auth";
import resolvers from "talk-server/graph/tenant/resolvers";
import { LOCALES } from "talk-server/graph/tenant/resolvers/LOCALES";
export default function getTenantSchema() {
const schema = loadSchema("tenant", resolvers as IResolvers);
@@ -10,5 +16,15 @@ export default function getTenantSchema() {
// Attach the directive resolvers.
attachDirectiveResolvers(schema, { auth });
// Attach the GraphQL enum fields.
addResolveFunctionsToSchema({
schema,
resolvers: {
// For some reason, the resolver doesn't quite work without coercing the
// type.
LOCALES: LOCALES as IEnumResolver,
},
});
return schema;
}
@@ -825,6 +825,16 @@ type ReactionConfiguration {
## Settings
################################################################################
"""
LOCALES list all the supported locales in a modified BCP 47 format, where the
hyphen is replaced by an underscore.
"""
enum LOCALES {
en_US
es
de
}
"""
Settings stores the global settings for a given Tenant.
"""
@@ -840,10 +850,15 @@ type Settings {
domain: String! @auth(roles: [ADMIN])
"""
domains will return a given list of whitelisted domains.
domains will return a given list of permitted domains.
"""
domains: [String!] @auth(roles: [ADMIN])
"""
locale is the specified locale for this Tenant.
"""
locale: LOCALES!
"""
moderation is the moderation mode for all Stories on the site.
"""